import asyncio
import html
import json
import logging
import math
import os
from dataclasses import dataclass, asdict, field
from datetime import datetime, timedelta, timezone, time
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from zoneinfo import ZoneInfo

import httpx
import yfinance as yf
from telegram import Update
from telegram.constants import ParseMode
from telegram.ext import (
    Application,
    CallbackContext,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

def _load_dotenv(path: Path) -> None:
    if not path.exists():
        return
    for raw_line in path.read_text().splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        if key and key not in os.environ:
            os.environ[key] = value


BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(exist_ok=True)

WATCHLIST_FILE = DATA_DIR / "watchlist.json"
USAGE_FILE = DATA_DIR / "usage.json"

_load_dotenv(BASE_DIR / ".env")

TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_DEFAULT_CHAT_ID = os.getenv("TELEGRAM_DEFAULT_CHAT_ID", "")
BOT_TIMEZONE = os.getenv("BOT_TIMEZONE", "America/New_York")
PREMARKET_HOUR = int(os.getenv("PREMARKET_HOUR", "8"))
PREMARKET_MINUTE = int(os.getenv("PREMARKET_MINUTE", "30"))

FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY", "")
FINNHUB_BASE_URL = "https://finnhub.io/api/v1"
ALPHA_VANTAGE_API_KEY = os.getenv("ALPHA_VANTAGE_API_KEY", "")
ALPHA_VANTAGE_BASE_URL = "https://www.alphavantage.co/query"

GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
GROQ_BASE_URL = "https://api.groq.com/openai/v1/chat/completions"
MAX_GROQ_CALLS_PER_DAY = int(os.getenv("MAX_GROQ_CALLS_PER_DAY", "25"))
OPTION_MIN_DTE = int(os.getenv("OPTION_MIN_DTE", "21"))
OPTION_MAX_DTE = int(os.getenv("OPTION_MAX_DTE", "45"))
OPTION_MIN_OTM_PCT = float(os.getenv("OPTION_MIN_OTM_PCT", "0.03"))
OPTION_MAX_OTM_PCT = float(os.getenv("OPTION_MAX_OTM_PCT", "0.20"))
OPTION_MIN_BID = float(os.getenv("OPTION_MIN_BID", "0.10"))
OPTION_MIN_OPEN_INTEREST = int(os.getenv("OPTION_MIN_OPEN_INTEREST", "50"))
OPTION_MAX_SPREAD_PCT = float(os.getenv("OPTION_MAX_SPREAD_PCT", "0.35"))
OPTION_MIN_DELTA = float(os.getenv("OPTION_MIN_DELTA", "0.15"))
OPTION_MAX_DELTA = float(os.getenv("OPTION_MAX_DELTA", "0.35"))
OPTION_RISK_FREE_RATE = float(os.getenv("OPTION_RISK_FREE_RATE", "0.04"))

logging.basicConfig(
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    level=logging.INFO,
)
logger = logging.getLogger("options-risk-bot")


@dataclass
class NewsItem:
    source: str
    headline: str
    summary: str
    url: str
    published_at: str


@dataclass
class EarningsInfo:
    date: Optional[str] = None
    hour: Optional[str] = None
    eps_estimate: Optional[float] = None
    revenue_estimate: Optional[float] = None


@dataclass
class TickerSnapshot:
    ticker: str
    current_price: Optional[float] = None
    previous_close: Optional[float] = None
    premarket_price: Optional[float] = None
    premarket_change_pct: Optional[float] = None
    market_cap: Optional[float] = None
    average_volume: Optional[float] = None
    vix_level: Optional[float] = None
    is_leveraged_product: bool = False
    two_hundred_day_avg: Optional[float] = None
    above_200d_ma: Optional[bool] = None
    fifty_two_week_high: Optional[float] = None
    distance_from_52w_high_pct: Optional[float] = None
    debt_to_equity: Optional[float] = None
    free_cash_flow: Optional[float] = None
    weekly_rsi: Optional[float] = None
    weekly_macd: Optional[float] = None
    weekly_macd_signal: Optional[float] = None
    support_level: Optional[float] = None
    near_support: Optional[bool] = None
    earnings_date: Optional[str] = None
    earnings_days_away: Optional[int] = None
    ex_dividend_date: Optional[str] = None
    dividend_days_away: Optional[int] = None
    notes: Optional[str] = None


@dataclass
class RiskAssessment:
    ticker: str
    current_price: Optional[float]
    sentiment: str
    impact: str
    csp_safe: bool
    covered_call_safe: bool
    overall_risk: str
    summary: str
    reasons: List[str]
    action: str
    wheel_suitability: str = "caution"
    wheel_reasons: List[str] = field(default_factory=list)
    recommended_trade: Optional[Dict[str, Any]] = None
    headlines: List[Dict[str, str]] = field(default_factory=list)
    source_mode: str = "rules"


def _read_json_file(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    try:
        return json.loads(path.read_text())
    except Exception:
        return default


def _write_json_file(path: Path, data: Any) -> None:
    path.write_text(json.dumps(data, indent=2))


def get_watchlist() -> List[str]:
    data = _read_json_file(WATCHLIST_FILE, {"tickers": []})
    tickers = data.get("tickers", [])
    return sorted({t.strip().upper() for t in tickers if t.strip()})


def save_watchlist(tickers: List[str]) -> None:
    _write_json_file(WATCHLIST_FILE, {"tickers": sorted({t.upper() for t in tickers})})


def get_usage() -> Dict[str, Any]:
    today = datetime.now(ZoneInfo(BOT_TIMEZONE)).date().isoformat()
    data = _read_json_file(USAGE_FILE, {"date": today, "groq_calls": 0})
    if data.get("date") != today:
        data = {"date": today, "groq_calls": 0}
        _write_json_file(USAGE_FILE, data)
    return data


def increment_groq_usage() -> None:
    data = get_usage()
    data["groq_calls"] = int(data.get("groq_calls", 0)) + 1
    _write_json_file(USAGE_FILE, data)


def groq_calls_remaining() -> int:
    data = get_usage()
    return max(0, MAX_GROQ_CALLS_PER_DAY - int(data.get("groq_calls", 0)))


def _first_number(*values: Any) -> Optional[float]:
    for value in values:
        if isinstance(value, (int, float)):
            return float(value)
        if isinstance(value, str):
            try:
                return float(value.replace(",", "").strip())
            except Exception:
                continue
    return None


def _days_until(date_str: Optional[str]) -> Optional[int]:
    if not date_str:
        return None
    try:
        target = datetime.fromisoformat(date_str).date()
        today = datetime.now(ZoneInfo(BOT_TIMEZONE)).date()
        return (target - today).days
    except Exception:
        return None


def _iso_from_unix(value: Any) -> str:
    if isinstance(value, (int, float)):
        return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
    return ""


def _iso_from_alpha_timestamp(value: Optional[str]) -> str:
    if not value:
        return ""
    try:
        return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=timezone.utc).isoformat()
    except Exception:
        return ""


def _iso_from_yahoo_timestamp(value: Any) -> str:
    if isinstance(value, str):
        try:
            return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat()
        except Exception:
            return ""
    return _iso_from_unix(value)


def _published_sort_key(item: NewsItem) -> tuple[str, str]:
    return (item.published_at or "", item.headline.lower())


def merge_news_items(*news_groups: List[NewsItem], limit: int = 25) -> List[NewsItem]:
    merged: List[NewsItem] = []
    seen: set[tuple[str, str]] = set()
    for group in news_groups:
        for item in group:
            headline = item.headline.strip()
            url = item.url.strip()
            if not headline:
                continue
            key = (headline.lower(), url.lower())
            if key in seen:
                continue
            seen.add(key)
            merged.append(item)
    merged.sort(key=_published_sort_key, reverse=True)
    return merged[:limit]


LEVERAGED_TICKER_DENYLIST = {
    "SOXL", "SOXS", "TQQQ", "SQQQ", "UPRO", "SPXU", "TSLL", "TSLQ", "NVDL", "NVDQ",
    "FNGU", "FNGD", "LABU", "LABD", "TECL", "TECS", "BULZ", "BERZ",
}


def _looks_leveraged_product(ticker: str, info: Dict[str, Any]) -> bool:
    if ticker.upper() in LEVERAGED_TICKER_DENYLIST:
        return True
    text = " ".join(
        str(info.get(key, ""))
        for key in ("longName", "shortName", "fundFamily", "category")
    ).lower()
    markers = ["2x", "3x", "ultra", "ultrapro", "leveraged", "bull", "bear"]
    return any(marker in text for marker in markers) and "etf" in text


def _normalize_debt_to_equity(value: Optional[float]) -> Optional[float]:
    if value is None:
        return None
    return value / 100 if value > 10 else value


def _days_to_expiry(expiry: str) -> Optional[int]:
    try:
        target = datetime.fromisoformat(expiry).date()
        today = datetime.now(ZoneInfo(BOT_TIMEZONE)).date()
        return (target - today).days
    except Exception:
        return None


def _norm_cdf(x: float) -> float:
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def _option_greeks_estimate(
    option_type: str,
    spot: float,
    strike: float,
    dte: int,
    implied_vol: Optional[float],
    risk_free_rate: float = OPTION_RISK_FREE_RATE,
) -> tuple[Optional[float], Optional[float]]:
    if spot <= 0 or strike <= 0 or dte <= 0:
        return None, None
    sigma = implied_vol if implied_vol and implied_vol > 0 else 0.35
    t = dte / 365.0
    if sigma <= 0 or t <= 0:
        return None, None
    try:
        d1 = (math.log(spot / strike) + (risk_free_rate + 0.5 * sigma * sigma) * t) / (sigma * math.sqrt(t))
        d2 = d1 - sigma * math.sqrt(t)
    except Exception:
        return None, None
    if option_type == "CSP":
        delta = _norm_cdf(d1) - 1.0
        assignment_prob = _norm_cdf(-d2)
    else:
        delta = _norm_cdf(d1)
        assignment_prob = _norm_cdf(d2)
    return abs(delta), assignment_prob


def _compute_weekly_indicators(history: Any, current_price: Optional[float]) -> Dict[str, Union[float, bool, None]]:
    result: Dict[str, Union[float, bool, None]] = {
        "weekly_rsi": None,
        "weekly_macd": None,
        "weekly_macd_signal": None,
        "support_level": None,
        "near_support": None,
    }
    if history is None or getattr(history, "empty", True):
        return result

    closes = history["Close"].dropna()
    lows = history["Low"].dropna() if "Low" in history else closes
    if len(closes) < 20:
        return result

    delta = closes.diff()
    gains = delta.clip(lower=0)
    losses = -delta.clip(upper=0)
    avg_gain = gains.rolling(14).mean()
    avg_loss = losses.rolling(14).mean()
    rs = avg_gain / avg_loss.replace(0, float("nan"))
    rsi = 100 - (100 / (1 + rs))
    latest_rsi = rsi.dropna()
    if not latest_rsi.empty:
        result["weekly_rsi"] = float(latest_rsi.iloc[-1])

    ema12 = closes.ewm(span=12, adjust=False).mean()
    ema26 = closes.ewm(span=26, adjust=False).mean()
    macd = ema12 - ema26
    signal = macd.ewm(span=9, adjust=False).mean()
    macd_valid = macd.dropna()
    signal_valid = signal.dropna()
    if not macd_valid.empty and not signal_valid.empty:
        result["weekly_macd"] = float(macd_valid.iloc[-1])
        result["weekly_macd_signal"] = float(signal_valid.iloc[-1])

    recent_lows = lows.tail(26)
    if not recent_lows.empty:
        support_level = float(recent_lows.min())
        result["support_level"] = support_level
        if current_price not in (None, 0) and support_level > 0:
            result["near_support"] = current_price <= support_level * 1.03

    return result


def contains_keywords(items: List[NewsItem], keywords: List[str]) -> bool:
    haystack = " ".join(f"{item.headline} {item.summary}".lower() for item in items)
    return any(word.lower() in haystack for word in keywords)


def select_impactful_headlines(news: List[NewsItem], limit: int = 5) -> List[Dict[str, str]]:
    impactful_keywords = [
        "upgrade",
        "upgraded",
        "downgrade",
        "downgraded",
        "raises target",
        "cuts target",
        "price target",
        "earnings",
        "guidance",
        "lawsuit",
        "sues",
        "sued",
        "investigation",
        "probe",
        "sec",
        "fda",
        "surges",
        "plunges",
        "jumps",
        "drops",
        "soars",
        "tumbles",
    ]
    headlines: List[Dict[str, str]] = []
    for item in news:
        headline = item.headline.strip()
        if not headline:
            continue
        haystack = f"{item.headline} {item.summary}".lower()
        if any(keyword in haystack for keyword in impactful_keywords):
            headlines.append({"headline": headline, "url": item.url.strip(), "source": item.source.strip()})
        if len(headlines) >= limit:
            break
    return headlines


def evaluate_wheel_suitability(snapshot: TickerSnapshot) -> tuple[str, List[str]]:
    reasons: List[str] = []
    suitability = "good"

    if snapshot.is_leveraged_product:
        suitability = "avoid"
        reasons.append("Leveraged ETFs and ETPs are poor wheel candidates.")

    if snapshot.vix_level is not None and snapshot.vix_level > 30:
        suitability = "avoid"
        reasons.append(f"VIX is elevated at {snapshot.vix_level:.2f}, which raises regime risk.")

    if snapshot.market_cap is not None and snapshot.market_cap < 2_000_000_000:
        suitability = "avoid"
        reasons.append(
            f"Market cap is only ${snapshot.market_cap / 1_000_000_000:.2f}B, below the $2B floor."
        )

    if snapshot.average_volume is not None and snapshot.average_volume < 200_000:
        suitability = "avoid"
        reasons.append(f"Average volume is only {snapshot.average_volume:,.0f}, which is thin.")

    if snapshot.current_price is not None and snapshot.current_price < 10:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Price is under $10, which often means weaker option liquidity.")

    if snapshot.weekly_rsi is not None and snapshot.weekly_rsi < 30:
        if (
            snapshot.weekly_macd is not None
            and snapshot.weekly_macd_signal is not None
            and snapshot.weekly_macd > snapshot.weekly_macd_signal
        ):
            reasons.append(
                f"Weekly RSI is oversold at {snapshot.weekly_rsi:.2f}, but MACD is turning up."
            )
        else:
            suitability = "avoid"
            reasons.append(
                f"Weekly RSI is oversold at {snapshot.weekly_rsi:.2f} and MACD is not confirming upward momentum."
            )
    elif snapshot.weekly_rsi is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Weekly RSI data is unavailable.")

    if snapshot.near_support is False:
        suitability = "avoid"
        reasons.append("Price is not near a clear 1-year weekly support level.")
    elif snapshot.near_support is True and snapshot.support_level is not None:
        reasons.append(f"Price is trading near weekly support around ${snapshot.support_level:.2f}.")
    else:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Support-level check is unavailable.")

    if snapshot.above_200d_ma is False:
        suitability = "avoid"
        reasons.append("Price is below the 200-day average, so the trend filter fails.")
    elif snapshot.above_200d_ma is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("200-day trend data is unavailable.")

    if snapshot.distance_from_52w_high_pct is not None and snapshot.distance_from_52w_high_pct > -15:
        if suitability == "good":
            suitability = "caution"
        reasons.append(
            f"Price is only {abs(snapshot.distance_from_52w_high_pct):.2f}% below the 52-week high."
        )
    elif snapshot.distance_from_52w_high_pct is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("52-week high data is unavailable.")

    if snapshot.debt_to_equity is not None and snapshot.debt_to_equity > 1.5:
        suitability = "avoid"
        reasons.append(f"Debt-to-equity is {snapshot.debt_to_equity:.2f}, above the 1.5 cap.")
    elif snapshot.debt_to_equity is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Debt-to-equity data is unavailable.")

    if snapshot.free_cash_flow is not None and snapshot.free_cash_flow <= 0:
        suitability = "avoid"
        reasons.append("Free cash flow is not positive.")
    elif snapshot.free_cash_flow is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Free cash flow data is unavailable.")

    if snapshot.market_cap is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Market cap data is unavailable, so quality filtering is incomplete.")

    if snapshot.average_volume is None:
        if suitability == "good":
            suitability = "caution"
        reasons.append("Average volume data is unavailable, so liquidity filtering is incomplete.")

    if not reasons:
        reasons.append("Passes the basic wheel filters for size, liquidity, and market regime.")

    return suitability, reasons


class FinnhubProvider:
    def __init__(self, api_key: str):
        self.api_key = api_key

    async def get_company_news(self, ticker: str, days_back: int = 2) -> List[NewsItem]:
        if not self.api_key:
            return []

        to_date = datetime.now(timezone.utc).date()
        from_date = to_date - timedelta(days=days_back)

        params = {
            "symbol": ticker,
            "from": from_date.isoformat(),
            "to": to_date.isoformat(),
            "token": self.api_key,
        }

        async with httpx.AsyncClient(timeout=20) as client:
            r = await client.get(f"{FINNHUB_BASE_URL}/company-news", params=params)
            r.raise_for_status()
            data = r.json()

        items: List[NewsItem] = []
        for row in data[:10]:
            dt = row.get("datetime")
            published = (
                datetime.fromtimestamp(dt, tz=timezone.utc).isoformat()
                if isinstance(dt, (int, float))
                else ""
            )
            items.append(
                NewsItem(
                    source=row.get("source", "Unknown"),
                    headline=row.get("headline", ""),
                    summary=row.get("summary", ""),
                    url=row.get("url", ""),
                    published_at=published,
                )
            )
        return items

    async def get_earnings(self, ticker: str, days_ahead: int = 21) -> EarningsInfo:
        if not self.api_key:
            return EarningsInfo()

        today = datetime.now(timezone.utc).date()
        to_date = today + timedelta(days=days_ahead)
        params = {
            "symbol": ticker,
            "from": today.isoformat(),
            "to": to_date.isoformat(),
            "token": self.api_key,
        }

        async with httpx.AsyncClient(timeout=20) as client:
            r = await client.get(f"{FINNHUB_BASE_URL}/calendar/earnings", params=params)
            r.raise_for_status()
            data = r.json()

        cal = data.get("earningsCalendar", []) or []
        if not cal:
            return EarningsInfo()

        row = sorted(cal, key=lambda x: x.get("date", "9999-12-31"))[0]
        return EarningsInfo(
            date=row.get("date"),
            hour=row.get("hour"),
            eps_estimate=row.get("epsEstimate"),
            revenue_estimate=row.get("revenueEstimate"),
        )


class AlphaVantageProvider:
    def __init__(self, api_key: str, base_url: str = ALPHA_VANTAGE_BASE_URL):
        self.api_key = api_key
        self.base_url = base_url

    async def get_company_news(self, ticker: str, limit: int = 10) -> List[NewsItem]:
        if not self.api_key:
            return []

        params = {
            "function": "NEWS_SENTIMENT",
            "tickers": ticker,
            "sort": "LATEST",
            "limit": str(limit),
            "apikey": self.api_key,
        }

        async with httpx.AsyncClient(timeout=20) as client:
            r = await client.get(self.base_url, params=params)
            r.raise_for_status()
            data = r.json()

        feed = data.get("feed", []) or []
        items: List[NewsItem] = []
        for row in feed[:limit]:
            items.append(
                NewsItem(
                    source=row.get("source", "Alpha Vantage"),
                    headline=row.get("title", ""),
                    summary=row.get("summary", ""),
                    url=row.get("url", ""),
                    published_at=_iso_from_alpha_timestamp(row.get("time_published")),
                )
            )
        return items

    async def get_quote(self, ticker: str) -> Dict[str, Optional[float]]:
        if not self.api_key:
            return {}

        params = {
            "function": "GLOBAL_QUOTE",
            "symbol": ticker,
            "apikey": self.api_key,
        }

        async with httpx.AsyncClient(timeout=20) as client:
            r = await client.get(self.base_url, params=params)
            r.raise_for_status()
            data = r.json()

        quote = data.get("Global Quote", {}) or {}
        price = _first_number(quote.get("05. price"))
        previous_close = _first_number(quote.get("08. previous close"))
        return {
            "current_price": price,
            "previous_close": previous_close,
        }


class YahooMarketProvider:
    async def get_company_news(self, ticker: str, limit: int = 10) -> List[NewsItem]:
        def _fetch() -> Any:
            return getattr(yf.Ticker(ticker), "news", []) or []

        raw_items = await asyncio.to_thread(_fetch)
        items: List[NewsItem] = []
        for row in raw_items[:limit]:
            content = row.get("content", {}) if isinstance(row, dict) else {}
            title = ""
            summary = ""
            url = ""
            source = "Yahoo Finance"
            published_at = ""

            if content:
                title = content.get("title", "")
                summary = content.get("summary", "")
                canonical = content.get("canonicalUrl", {}) or {}
                url = canonical.get("url", "") or content.get("clickThroughUrl", {}).get("url", "")
                provider = content.get("provider", {}) or {}
                source = provider.get("displayName", source)
                published_at = _iso_from_yahoo_timestamp(content.get("pubDate"))
            elif isinstance(row, dict):
                title = row.get("title", "")
                summary = row.get("summary", "")
                url = row.get("link", "")
                source = row.get("publisher", source)
                published_at = _iso_from_yahoo_timestamp(row.get("providerPublishTime"))

            if title:
                items.append(
                    NewsItem(
                        source=source,
                        headline=title,
                        summary=summary,
                        url=url,
                        published_at=published_at,
                    )
                )
        return items

    async def get_vix_level(self) -> Optional[float]:
        def _fetch() -> Optional[float]:
            vix = yf.Ticker("^VIX")
            fast = getattr(vix, "fast_info", None) or {}
            info = vix.info or {}
            return _first_number(
                fast.get("lastPrice"),
                fast.get("regularMarketPrice"),
                info.get("regularMarketPrice"),
                info.get("previousClose"),
            )

        try:
            return await asyncio.to_thread(_fetch)
        except Exception:
            return None

    async def get_best_wheel_trade(
        self,
        ticker: str,
        current_price: Optional[float],
        csp_safe: bool,
        covered_call_safe: bool,
    ) -> Optional[Dict[str, Any]]:
        if current_price in (None, 0):
            return None

        def _fetch() -> Optional[Dict[str, Any]]:
            t = yf.Ticker(ticker)
            expirations = list(getattr(t, "options", []) or [])
            best_trade: Optional[Dict[str, Any]] = None

            for expiry in expirations:
                dte = _days_to_expiry(expiry)
                if dte is None or dte < OPTION_MIN_DTE or dte > OPTION_MAX_DTE:
                    continue

                try:
                    chain = t.option_chain(expiry)
                except Exception:
                    continue

                for option_type, frame, risk_aligned in (
                    ("CSP", getattr(chain, "puts", None), csp_safe),
                    ("CC", getattr(chain, "calls", None), covered_call_safe),
                ):
                    if frame is None or frame.empty:
                        continue
                    for row in frame.to_dict("records"):
                        strike = _first_number(row.get("strike"))
                        bid = _first_number(row.get("bid"))
                        ask = _first_number(row.get("ask"))
                        implied_vol = _first_number(row.get("impliedVolatility"))
                        open_interest = int(_first_number(row.get("openInterest")) or 0)
                        volume = int(_first_number(row.get("volume")) or 0)
                        if strike is None or bid is None or bid < OPTION_MIN_BID:
                            continue
                        if open_interest < OPTION_MIN_OPEN_INTEREST and volume < OPTION_MIN_OPEN_INTEREST:
                            continue
                        if ask is not None and ask > 0 and (ask - bid) / ask > OPTION_MAX_SPREAD_PCT:
                            continue

                        otm_pct = (
                            (current_price - strike) / current_price
                            if option_type == "CSP"
                            else (strike - current_price) / current_price
                        )
                        if otm_pct < OPTION_MIN_OTM_PCT or otm_pct > OPTION_MAX_OTM_PCT:
                            continue

                        delta_abs, assignment_prob = _option_greeks_estimate(
                            option_type,
                            current_price,
                            strike,
                            dte,
                            implied_vol,
                        )
                        if delta_abs is None or delta_abs < OPTION_MIN_DELTA or delta_abs > OPTION_MAX_DELTA:
                            continue

                        collateral = strike * 100 if option_type == "CSP" else current_price * 100
                        if collateral <= 0:
                            continue

                        premium = bid * 100
                        annualized_yield_pct = (premium / collateral) * (365 / dte) * 100
                        candidate = {
                            "type": option_type,
                            "expiry": expiry,
                            "dte": dte,
                            "strike": strike,
                            "bid": bid,
                            "ask": ask,
                            "premium": premium,
                            "otm_pct": otm_pct * 100,
                            "delta": delta_abs,
                            "assignment_prob": assignment_prob * 100 if assignment_prob is not None else None,
                            "implied_volatility": implied_vol * 100 if implied_vol is not None else None,
                            "open_interest": open_interest,
                            "volume": volume,
                            "annualized_yield_pct": annualized_yield_pct,
                            "risk_aligned": risk_aligned,
                        }
                        if best_trade is None or annualized_yield_pct > best_trade["annualized_yield_pct"]:
                            best_trade = candidate
            return best_trade

        try:
            return await asyncio.to_thread(_fetch)
        except Exception:
            return None

    def get_snapshot(
        self,
        ticker: str,
        earnings: EarningsInfo,
        quote_fallback: Optional[Dict[str, Optional[float]]] = None,
        vix_level: Optional[float] = None,
    ) -> TickerSnapshot:
        try:
            t = yf.Ticker(ticker)
            info = t.info or {}
            fast = getattr(t, "fast_info", None) or {}
            quote_fallback = quote_fallback or {}

            current_price = _first_number(
                info.get("regularMarketPrice"),
                fast.get("lastPrice"),
                fast.get("regularMarketPrice"),
                quote_fallback.get("current_price"),
            )
            previous_close = _first_number(
                info.get("regularMarketPreviousClose"),
                fast.get("previousClose"),
                quote_fallback.get("previous_close"),
            )
            premarket_price = _first_number(info.get("preMarketPrice"))

            premarket_change_pct = None
            if premarket_price is not None and previous_close not in (None, 0):
                premarket_change_pct = ((premarket_price - previous_close) / previous_close) * 100
            elif current_price is not None and previous_close not in (None, 0):
                premarket_change_pct = ((current_price - previous_close) / previous_close) * 100

            two_hundred_day_avg = _first_number(info.get("twoHundredDayAverage"))
            above_200d_ma = None
            if current_price is not None and two_hundred_day_avg not in (None, 0):
                above_200d_ma = current_price > two_hundred_day_avg

            fifty_two_week_high = _first_number(info.get("fiftyTwoWeekHigh"))
            distance_from_52w_high_pct = None
            if current_price is not None and fifty_two_week_high not in (None, 0):
                distance_from_52w_high_pct = ((current_price - fifty_two_week_high) / fifty_two_week_high) * 100

            debt_to_equity = _normalize_debt_to_equity(_first_number(info.get("debtToEquity")))
            free_cash_flow = _first_number(info.get("freeCashflow"))
            weekly_history = t.history(period="1y", interval="1wk", auto_adjust=False)
            weekly_indicators = _compute_weekly_indicators(weekly_history, current_price)

            ex_dividend_ts = info.get("exDividendDate")
            ex_dividend_date = None
            dividend_days_away = None
            if ex_dividend_ts:
                try:
                    ex_dividend_date = datetime.fromtimestamp(
                        ex_dividend_ts, tz=timezone.utc
                    ).date().isoformat()
                    dividend_days_away = _days_until(ex_dividend_date)
                except Exception:
                    pass

            return TickerSnapshot(
                ticker=ticker,
                current_price=current_price,
                previous_close=previous_close,
                premarket_price=premarket_price,
                premarket_change_pct=round(premarket_change_pct, 2)
                if premarket_change_pct is not None
                else None,
                market_cap=_first_number(info.get("marketCap")),
                average_volume=_first_number(
                    info.get("averageVolume"),
                    info.get("averageDailyVolume10Day"),
                    info.get("averageVolume10days"),
                ),
                vix_level=round(vix_level, 2) if vix_level is not None else None,
                is_leveraged_product=_looks_leveraged_product(ticker, info),
                two_hundred_day_avg=two_hundred_day_avg,
                above_200d_ma=above_200d_ma,
                fifty_two_week_high=fifty_two_week_high,
                distance_from_52w_high_pct=round(distance_from_52w_high_pct, 2)
                if distance_from_52w_high_pct is not None
                else None,
                debt_to_equity=debt_to_equity,
                free_cash_flow=free_cash_flow,
                weekly_rsi=weekly_indicators["weekly_rsi"],
                weekly_macd=weekly_indicators["weekly_macd"],
                weekly_macd_signal=weekly_indicators["weekly_macd_signal"],
                support_level=weekly_indicators["support_level"],
                near_support=weekly_indicators["near_support"],
                earnings_date=earnings.date,
                earnings_days_away=_days_until(earnings.date),
                ex_dividend_date=ex_dividend_date,
                dividend_days_away=dividend_days_away,
            )
        except Exception as exc:
            return TickerSnapshot(
                ticker=ticker,
                vix_level=round(vix_level, 2) if vix_level is not None else None,
                earnings_date=earnings.date,
                earnings_days_away=_days_until(earnings.date),
                notes=f"Market snapshot unavailable: {exc}",
            )


def build_summary(ticker: str, snapshot: TickerSnapshot, overall_risk: str, reasons: List[str]) -> str:
    parts = [f"{ticker} screens as {overall_risk.upper()} risk today."]
    if snapshot.earnings_days_away is not None:
        parts.append(f"Earnings in {snapshot.earnings_days_away} day(s).")
    return " ".join(parts)


def build_action(overall_risk: str, csp_safe: bool, covered_call_safe: bool) -> str:
    if overall_risk == "high":
        return "Avoid opening short premium today unless you intentionally want event risk."
    if not csp_safe and covered_call_safe:
        return "Covered calls may be acceptable, but cash-secured puts look exposed."
    if csp_safe and not covered_call_safe:
        return "Cash-secured puts look safer than covered calls today."
    if overall_risk == "medium":
        return "Use caution. Consider smaller size, wider strikes, or waiting."
    return "No obvious red flag from the free checks. Still review chart, liquidity, and macro context before selling premium."


def analyze_rules(ticker: str, news: List[NewsItem], snapshot: TickerSnapshot) -> RiskAssessment:
    reasons: List[str] = []
    wheel_suitability, wheel_reasons = evaluate_wheel_suitability(snapshot)
    sentiment = "neutral"
    impact = "low"
    overall_risk = "low"
    csp_safe = True
    covered_call_safe = True

    downgrade_words = ["downgrade", "cuts target", "cut target", "lowers target", "price target cut"]
    rumor_words = ["rumor", "talks", "reportedly", "unconfirmed", "takeover"]
    earnings_words = ["earnings", "guidance", "revenue", "eps"]

    if snapshot.earnings_days_away is not None and 0 <= snapshot.earnings_days_away <= 7:
        overall_risk = "high"
        impact = "high"
        csp_safe = False
        covered_call_safe = False
        reasons.append(f"Earnings are in {snapshot.earnings_days_away} day(s).")

    if snapshot.premarket_change_pct is not None and abs(snapshot.premarket_change_pct) >= 2.0:
        overall_risk = "high"
        impact = "high"
        csp_safe = False
        reasons.append(f"Pre-market move is {snapshot.premarket_change_pct}%.")
        sentiment = "bearish" if snapshot.premarket_change_pct < 0 else "bullish"

    if contains_keywords(news, downgrade_words):
        overall_risk = "high"
        impact = "high"
        csp_safe = False
        reasons.append("Headlines include an analyst downgrade or target cut.")
        sentiment = "bearish"

    if overall_risk != "high" and contains_keywords(news, rumor_words):
        overall_risk = "medium"
        impact = "medium"
        csp_safe = False
        reasons.append("Rumor-driven headlines are present.")
        sentiment = "mixed"

    if overall_risk == "low" and contains_keywords(news, earnings_words):
        overall_risk = "medium"
        impact = "medium"
        reasons.append("Recent headlines mention earnings or guidance themes.")
        sentiment = "mixed"

    if snapshot.dividend_days_away is not None and 0 <= snapshot.dividend_days_away <= 5:
        covered_call_safe = False
        if overall_risk == "low":
            overall_risk = "medium"
            impact = "medium"
        reasons.append(
            f"Ex-dividend date is in {snapshot.dividend_days_away} day(s), which matters for covered calls."
        )

    if not reasons:
        reasons.append("No obvious event risk was detected from free news and market checks.")

    return RiskAssessment(
        ticker=ticker,
        current_price=snapshot.current_price,
        sentiment=sentiment,
        impact=impact,
        csp_safe=csp_safe,
        covered_call_safe=covered_call_safe,
        overall_risk=overall_risk,
        summary=build_summary(ticker, snapshot, overall_risk, reasons),
        reasons=reasons,
        action=build_action(overall_risk, csp_safe, covered_call_safe),
        wheel_suitability=wheel_suitability,
        wheel_reasons=wheel_reasons,
        headlines=select_impactful_headlines(news),
        source_mode="rules",
    )


async def try_groq_refine(
    assessment: RiskAssessment, news: List[NewsItem], snapshot: TickerSnapshot
) -> RiskAssessment:
    if not GROQ_API_KEY or groq_calls_remaining() <= 0:
        return assessment

    prompt = {
        "ticker": assessment.ticker,
        "rule_assessment": asdict(assessment),
        "snapshot": asdict(snapshot),
        "headlines": [asdict(x) for x in news[:5]],
        "task": "Rewrite this into a concise options-selling risk assessment. Keep the same risk level unless evidence clearly supports changing it. Return JSON only with keys: sentiment, impact, csp_safe, covered_call_safe, overall_risk, summary, reasons, action.",
    }

    headers = {
        "Authorization": f"Bearer {GROQ_API_KEY}",
        "Content-Type": "application/json",
    }
    body = {
        "model": GROQ_MODEL,
        "temperature": 0.2,
        "messages": [
            {"role": "system", "content": "You are a concise options-risk assistant. Return valid JSON only."},
            {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)},
        ],
        "response_format": {"type": "json_object"},
    }

    async with httpx.AsyncClient(timeout=30) as client:
        try:
            r = await client.post(GROQ_BASE_URL, headers=headers, json=body)
            r.raise_for_status()
            data = r.json()
            content = data["choices"][0]["message"]["content"]
            parsed = json.loads(content)
            increment_groq_usage()
            return RiskAssessment(
                ticker=assessment.ticker,
                current_price=assessment.current_price,
                sentiment=parsed.get("sentiment", assessment.sentiment),
                impact=parsed.get("impact", assessment.impact),
                csp_safe=bool(parsed.get("csp_safe", assessment.csp_safe)),
                covered_call_safe=bool(parsed.get("covered_call_safe", assessment.covered_call_safe)),
                overall_risk=parsed.get("overall_risk", assessment.overall_risk),
                summary=parsed.get("summary", assessment.summary),
                reasons=list(parsed.get("reasons", assessment.reasons)),
                action=parsed.get("action", assessment.action),
                wheel_suitability=assessment.wheel_suitability,
                wheel_reasons=assessment.wheel_reasons,
                recommended_trade=assessment.recommended_trade,
                headlines=assessment.headlines,
                source_mode="groq",
            )
        except Exception:
            return assessment


finnhub_provider = FinnhubProvider(FINNHUB_API_KEY)
alpha_vantage_provider = AlphaVantageProvider(ALPHA_VANTAGE_API_KEY)
yahoo_provider = YahooMarketProvider()


async def analyze_ticker(ticker: str) -> RiskAssessment:
    finnhub_news, alpha_news, yahoo_news, earnings, alpha_quote, vix_level = await asyncio.gather(
        finnhub_provider.get_company_news(ticker),
        alpha_vantage_provider.get_company_news(ticker),
        yahoo_provider.get_company_news(ticker),
        finnhub_provider.get_earnings(ticker),
        alpha_vantage_provider.get_quote(ticker),
        yahoo_provider.get_vix_level(),
        return_exceptions=True,
    )
    if isinstance(finnhub_news, Exception):
        logger.warning("Finnhub news failed for %s: %s", ticker, finnhub_news)
        finnhub_news = []
    if isinstance(alpha_news, Exception):
        logger.warning("Alpha Vantage news failed for %s: %s", ticker, alpha_news)
        alpha_news = []
    if isinstance(yahoo_news, Exception):
        logger.warning("Yahoo Finance news failed for %s: %s", ticker, yahoo_news)
        yahoo_news = []
    if isinstance(earnings, Exception):
        logger.warning("Finnhub earnings failed for %s: %s", ticker, earnings)
        earnings = EarningsInfo()
    if isinstance(alpha_quote, Exception):
        logger.warning("Alpha Vantage quote failed for %s: %s", ticker, alpha_quote)
        alpha_quote = {}
    if isinstance(vix_level, Exception):
        logger.warning("VIX fetch failed while analyzing %s: %s", ticker, vix_level)
        vix_level = None
    news = merge_news_items(finnhub_news, alpha_news, yahoo_news)
    snapshot = yahoo_provider.get_snapshot(ticker, earnings, alpha_quote, vix_level)
    base = analyze_rules(ticker, news, snapshot)
    base.recommended_trade = await yahoo_provider.get_best_wheel_trade(
        ticker,
        snapshot.current_price,
        base.csp_safe,
        base.covered_call_safe,
    )
    refined = await try_groq_refine(base, news, snapshot)
    refined.recommended_trade = base.recommended_trade
    return refined


async def analyze_watchlist(tickers: List[str]) -> List[RiskAssessment]:
    results = []
    for ticker in tickers:
        try:
            results.append(await analyze_ticker(ticker))
        except Exception as exc:
            results.append(
                RiskAssessment(
                    ticker=ticker,
                    current_price=None,
                    sentiment="mixed",
                    impact="high",
                    csp_safe=False,
                    covered_call_safe=False,
                    overall_risk="high",
                    summary=f"Analysis failed for {ticker}: {exc}",
                    reasons=["The bot could not complete the free checks."],
                    action="Do not trust this result until the error is fixed.",
                    wheel_suitability="caution",
                    wheel_reasons=["Wheel suitability could not be evaluated because the analysis failed."],
                    source_mode="error",
                )
            )
    rank = {"high": 0, "medium": 1, "low": 2}
    return sorted(results, key=lambda x: (rank.get(x.overall_risk, 9), x.ticker))


def risk_emoji(level: str) -> str:
    return {"high": "🚨", "medium": "⚠️", "low": "✅"}.get(level, "ℹ️")


def wheel_emoji(level: str) -> str:
    return {"good": "✅", "caution": "⚠️", "avoid": "🚫"}.get(level, "ℹ️")


def bool_icon(v: bool) -> str:
    return "✅" if v else "❌"


def format_price(value: Optional[float]) -> str:
    if value is None:
        return ""
    return f" ${value:.2f}"


def format_recommended_trade(trade: Optional[Dict[str, Any]]) -> str:
    if not trade:
        return "<b>Best Trade</b>\nNo CSP/CC candidate matched the current option parameters."
    alignment = "Yes" if trade.get("risk_aligned") else "No"
    ask_text = f"{trade['ask']:.2f}" if isinstance(trade.get("ask"), (int, float)) else "n/a"
    delta_text = f"{trade['delta']:.2f}" if isinstance(trade.get("delta"), (int, float)) else "n/a"
    assignment_text = (
        f"{trade['assignment_prob']:.2f}%"
        if isinstance(trade.get("assignment_prob"), (int, float))
        else "n/a"
    )
    return (
        "<b>Best Trade</b>\n"
        f"{trade['type']} {trade['strike']:.2f} exp {trade['expiry']} ({trade['dte']} DTE)\n"
        f"Bid/Ask: {trade['bid']:.2f} / {ask_text} | Premium: ${trade['premium']:.0f}\n"
        f"OTM: {trade['otm_pct']:.2f}% | Delta: {delta_text} | Assign Prob: {assignment_text}\n"
        f"Annualized Yield: {trade['annualized_yield_pct']:.2f}%\n"
        f"OI: {trade['open_interest']} | Vol: {trade['volume']} | Risk-Aligned: {alignment}"
    )


def format_assessment(item: RiskAssessment, include_headlines: bool = False) -> str:
    reasons_text = "\n".join(f"• {html.escape(r)}" for r in item.reasons[:4])
    wheel_text = "\n".join(f"• {html.escape(r)}" for r in item.wheel_reasons[:4])
    headlines_text = ""
    if include_headlines and item.headlines:
        escaped_headlines = "\n".join(
            (
                f'• <a href="{html.escape(headline.get("url", ""), quote=True)}">{html.escape(headline.get("headline", ""))}</a>'
                if headline.get("url")
                else f'• {html.escape(headline.get("headline", ""))}'
            )
            for headline in item.headlines[:5]
            if headline.get("headline")
        )
        headlines_text = f"\n<b>News</b>\n{escaped_headlines}"
    return (
        f"<b>{item.ticker}</b>{format_price(item.current_price)} {risk_emoji(item.overall_risk)}\n"
        f"Risk: <b>{item.overall_risk.title()}</b> | Impact: <b>{item.impact.title()}</b> | Sentiment: <b>{item.sentiment.title()}</b>\n"
        f"Wheel: <b>{item.wheel_suitability.title()}</b> {wheel_emoji(item.wheel_suitability)}\n"
        f"Options: CSP {bool_icon(item.csp_safe)} | Covered Call {bool_icon(item.covered_call_safe)}\n"
        f"<b>Summary</b>\n{html.escape(item.summary)}\n"
        f"<b>Why</b>\n{reasons_text}\n"
        f"<b>Wheel Notes</b>\n{wheel_text}\n"
        f"{format_recommended_trade(item.recommended_trade)}\n"
        f"{headlines_text}"
    )


def format_daily_report(items: List[RiskAssessment]) -> str:
    if not items:
        return "No tickers found. Use /add AAPL MSFT NVDA"

    high = sum(1 for x in items if x.overall_risk == "high")
    medium = sum(1 for x in items if x.overall_risk == "medium")
    low = sum(1 for x in items if x.overall_risk == "low")

    parts = [
        "<b>Pre-Market Options Risk Report</b>",
        f"High: {high} | Medium: {medium} | Low: {low}",
        f"Groq calls left today: {groq_calls_remaining()}",
        "",
    ]
    for item in items:
        parts.append(format_assessment(item, include_headlines=True))
        parts.append("")
    return "\n".join(parts).strip()


async def start_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Options risk bot is running.\n\n"
        "Commands:\n"
        "/watchlist - show watchlist\n"
        "/add AAPL MSFT - add tickers\n"
        "/remove TSLA - remove tickers\n"
        "/scan - run full scan now\n"
        "/risk NVDA - check one ticker\n"
        "/bearish - show flagged names\n"
        "/usage - show Groq usage"
    )


async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await start_cmd(update, context)


async def usage_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    data = get_usage()
    await update.message.reply_text(
        f"Date: {data['date']}\nGroq calls used: {data['groq_calls']}\nGroq calls left: {groq_calls_remaining()}"
    )


async def watchlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    tickers = get_watchlist()
    if not tickers:
        await update.message.reply_text("Your watchlist is empty. Example: /add AAPL MSFT NVDA")
        return
    await update.message.reply_text("Current watchlist: " + ", ".join(tickers))


async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    args = [a.upper().strip() for a in context.args if a.strip()]
    if not args:
        await update.message.reply_text("Usage: /add AAPL MSFT NVDA")
        return
    current = get_watchlist()
    new_list = sorted(set(current + args))
    save_watchlist(list(new_list))
    await update.message.reply_text("Updated watchlist: " + ", ".join(new_list))


async def remove_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    args = {a.upper().strip() for a in context.args if a.strip()}
    if not args:
        await update.message.reply_text("Usage: /remove TSLA")
        return
    current = get_watchlist()
    new_list = [t for t in current if t not in args]
    save_watchlist(new_list)
    await update.message.reply_text("Updated watchlist: " + (", ".join(new_list) if new_list else "(empty)"))


async def scan_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    tickers = get_watchlist()
    if not tickers:
        await update.message.reply_text("Your watchlist is empty. Use /add first.")
        return
    await update.message.reply_text(f"Running scan for: {', '.join(tickers)}")
    results = await analyze_watchlist(tickers)
    if not results:
        await update.message.reply_text("No results returned.")
        return

    summary = (
        "<b>Pre-Market Options Risk Report</b>\n"
        f"High: {sum(1 for x in results if x.overall_risk == 'high')} | "
        f"Medium: {sum(1 for x in results if x.overall_risk == 'medium')} | "
        f"Low: {sum(1 for x in results if x.overall_risk == 'low')}\n"
        f"Groq calls left today: {groq_calls_remaining()}"
    )
    await update.message.reply_text(summary, parse_mode=ParseMode.HTML, disable_web_page_preview=True)

    for item in results:
        await update.message.reply_text(
            format_assessment(item, include_headlines=True),
            parse_mode=ParseMode.HTML,
            disable_web_page_preview=True,
        )


async def risk_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    if not context.args:
        await update.message.reply_text("Usage: /risk NVDA")
        return
    ticker = context.args[0].upper().strip()
    await update.message.reply_text(f"Checking {ticker}...")
    result = await analyze_ticker(ticker)
    await update.message.reply_text(
        format_assessment(result),
        parse_mode=ParseMode.HTML,
        disable_web_page_preview=True,
    )


async def bearish_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    tickers = get_watchlist()
    if not tickers:
        await update.message.reply_text("Your watchlist is empty. Use /add first.")
        return
    results = await analyze_watchlist(tickers)
    risky = [
        x for x in results
        if x.overall_risk in {"high", "medium"} or not x.csp_safe or not x.covered_call_safe
    ]
    if not risky:
        await update.message.reply_text("Nothing flagged right now.")
        return
    text = "<b>Flagged Names</b>\n\n" + "\n\n".join(format_assessment(x) for x in risky)
    await update.message.reply_text(text, parse_mode=ParseMode.HTML, disable_web_page_preview=True)


async def chat_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Use commands for now: /scan, /risk TICKER, /watchlist, /add, /remove, /bearish, /usage."
    )


async def scheduled_premarket_report(context: CallbackContext) -> None:
    tickers = get_watchlist()
    if not tickers or not TELEGRAM_DEFAULT_CHAT_ID:
        return
    results = await analyze_watchlist(tickers)
    await context.bot.send_message(
        chat_id=TELEGRAM_DEFAULT_CHAT_ID,
        text=format_daily_report(results),
        parse_mode=ParseMode.HTML,
        disable_web_page_preview=True,
    )


def validate_env() -> None:
    if not TELEGRAM_BOT_TOKEN:
        raise RuntimeError("Missing TELEGRAM_BOT_TOKEN")


def build_application() -> Application:
    validate_env()
    app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()

    app.add_handler(CommandHandler("start", start_cmd))
    app.add_handler(CommandHandler("help", help_cmd))
    app.add_handler(CommandHandler("usage", usage_cmd))
    app.add_handler(CommandHandler("watchlist", watchlist_cmd))
    app.add_handler(CommandHandler("add", add_cmd))
    app.add_handler(CommandHandler("remove", remove_cmd))
    app.add_handler(CommandHandler("scan", scan_cmd))
    app.add_handler(CommandHandler("risk", risk_cmd))
    app.add_handler(CommandHandler("bearish", bearish_cmd))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, chat_handler))

    run_at = time(hour=PREMARKET_HOUR, minute=PREMARKET_MINUTE, tzinfo=ZoneInfo(BOT_TIMEZONE))
    app.job_queue.run_daily(scheduled_premarket_report, time=run_at, name="premarket-report")
    return app


def main() -> None:
    app = build_application()
    app.run_polling(close_loop=False)


if __name__ == "__main__":
    main()
