import os import time import threading import requests as req import pandas as pd import yfinance as yf from datetime import datetime, timezone, timedelta, time as dtime from zoneinfo import ZoneInfo from flask import Flask BOT_TOKEN = os.environ["BOT_TOKEN"] CHAT_ID = os.environ["CHAT_ID"] SAUDI_TZ = timezone(timedelta(hours=3)) # ───────────────────────────────────────────────────────────────────────────── # Telegram helpers # ───────────────────────────────────────────────────────────────────────────── _tg = req.Session() def _post(endpoint, **kw): url = f"https://api.telegram.org/bot{BOT_TOKEN}/{endpoint}" for _ in range(3): try: return _tg.post(url, timeout=12, **kw).json() except Exception as e: print(f"TG {endpoint}: {e}") time.sleep(1) return {} def send(msg, cid=None): cid = cid or CHAT_ID for chunk in [msg[i:i+4000] for i in range(0, len(msg), 4000)]: _post("sendMessage", data={"chat_id": cid, "text": chunk}) def send_id(msg, cid=None): r = _post("sendMessage", data={"chat_id": cid or CHAT_ID, "text": msg}) return r.get("result", {}).get("message_id") def edit(cid, mid, text): _post("editMessageText", json={"chat_id": cid, "message_id": mid, "text": text}) def keyboard(msg, btns, cid=None): _post("sendMessage", json={"chat_id": cid or CHAT_ID, "text": msg, "reply_markup": {"inline_keyboard": btns}}) def answer_cb(cid): _post("answerCallbackQuery", data={"callback_query_id": cid}) def pbar(done, total, w=18): f = int(w * done / total) if total else 0 pct = int(100 * done / total) if total else 0 return f"[{'█'*f}{'░'*(w-f)}] {pct}%" def _vol_24h(df: pd.DataFrame) -> int: """Sum volume for last ~26 bars (one full 6.5-hour trading session).""" return int(df.tail(26)["Volume"].sum()) def _fmt_vol(v: int) -> str: if v >= 1_000_000_000: return f"{v/1_000_000_000:.1f}B" if v >= 1_000_000: return f"{v/1_000_000:.1f}M" if v >= 1_000: return f"{v/1_000:.0f}K" return str(v) # ───────────────────────────────────────────────────────────────────────────── # yfinance data layer # ───────────────────────────────────────────────────────────────────────────── BATCH = 50 # symbols per yfinance call VOL_MIN = 1_000_000 # minimum 24h volume to show a signal _MH_START = dtime(9, 30) _MH_END = dtime(16, 0) _PM_START = dtime(4, 0) _PM_END = dtime(9, 30) def _to_et(idx): """Convert DatetimeIndex to US/Eastern.""" if idx.tz is None: return idx.tz_localize("UTC").tz_convert("America/New_York") return idx.tz_convert("America/New_York") def _filter_mh(df: pd.DataFrame) -> pd.DataFrame: """Keep only regular market hours bars (9:30–16:00 ET).""" et = _to_et(df.index) mask = (et.time >= _MH_START) & (et.time < _MH_END) out = df[mask].copy() out.index = et[mask] return out def _filter_pm(df: pd.DataFrame) -> pd.DataFrame: """Keep only pre-market bars (4:00–9:30 ET).""" et = _to_et(df.index) mask = (et.time >= _PM_START) & (et.time < _PM_END) out = df[mask].copy() out.index = et[mask] return out def _extract_sym(raw, sym, multi): """Extract per-symbol DataFrame from a yf.download result.""" if multi: names = raw.columns.names if names[0] == "Ticker": if sym not in raw.columns.get_level_values(0): return None return raw[sym].copy() else: return raw.xs(sym, axis=1, level=1).copy() if sym in raw.columns.get_level_values(1) else None return raw.copy() def fetch_batch(symbols: list[str]) -> dict: """ Download 15-min candles for up to BATCH symbols at once. Returns {symbol: DataFrame(OHLCV, ET-indexed, market-hours only)}. """ results = {} if not symbols: return results try: raw = yf.download( symbols, period="5d", interval="15m", group_by="ticker", auto_adjust=True, progress=False, threads=True, ) if raw.empty: return results multi = isinstance(raw.columns, pd.MultiIndex) for sym in symbols: try: df = _extract_sym(raw, sym, multi) if df is None or (hasattr(df, "empty") and df.empty): continue df = _filter_mh(df) df = df.dropna(subset=["Open", "Close"]) if len(df) >= 2: results[sym] = df except Exception as ex: print(f"fetch_batch sym={sym}: {ex}") except Exception as e: print(f"fetch_batch error: {e}") return results def fetch_batch_pm(symbols: list[str]) -> dict: """ Download 15-min candles INCLUDING pre/post market for up to BATCH symbols. Returns {symbol: DataFrame(OHLCV, ET-indexed, pre-market only 4:00–9:30)}. """ results = {} if not symbols: return results try: raw = yf.download( symbols, period="5d", interval="15m", group_by="ticker", auto_adjust=True, prepost=True, progress=False, threads=True, ) if raw.empty: return results multi = isinstance(raw.columns, pd.MultiIndex) for sym in symbols: try: df = _extract_sym(raw, sym, multi) if df is None or (hasattr(df, "empty") and df.empty): continue df = _filter_pm(df) df = df.dropna(subset=["Open", "Close"]) if len(df) >= 2: results[sym] = df except Exception as ex: print(f"fetch_batch_pm sym={sym}: {ex}") except Exception as e: print(f"fetch_batch_pm error: {e}") return results # ───────────────────────────────────────────────────────────────────────────── # Strategies # ───────────────────────────────────────────────────────────────────────────── def _bar_line(df, idx): row = df.iloc[idx] ts = df.index[idx].strftime("%m/%d %H:%M ET") pct = (row["Close"] - row["Open"]) / row["Open"] * 100 if row["Open"] else 0 return f"O:{row['Open']:.3f} C:{row['Close']:.3f} ({pct:+.1f}%) [{ts}]" # ── Deduplication — prevents re-alerting the same candle in auto scans ─────── _alerted_keys: set[str] = set() _alerted_date: str = "" def _is_new_signal(sym: str, candle_ts: str) -> bool: """Return True (and record) if this sym+candle hasn't been alerted today.""" global _alerted_keys, _alerted_date today = _now_et().strftime("%Y-%m-%d") if today != _alerted_date: _alerted_keys = set() _alerted_date = today key = f"{sym}@{candle_ts}" if key in _alerted_keys: return False _alerted_keys.add(key) return True def strat_pump_closed(sym, df, dedup: bool = False): """ Best 15-min candle of TODAY's session that moved ≥15% open→close. dedup=True (auto scans) — skips already-alerted candles. dedup=False (manual) — always shows. """ today = _now_et().date() closed = df.iloc[:-1] # exclude the live (unclosed) candle mask = [ts.date() == today for ts in closed.index] today_df = closed[mask] if today_df.empty: return None best_pct = 14.999 best_row = None best_ts = None for ts, row in today_df.iterrows(): if not row["Open"] or row["Open"] == 0: continue pct = (row["Close"] - row["Open"]) / row["Open"] * 100 if pct > best_pct: best_pct = pct best_row = row best_ts = ts if best_row is None: return None vol = _vol_24h(df) if vol < VOL_MIN: return None ts_str = best_ts.strftime("%H:%M ET") if dedup and not _is_new_signal(sym, ts_str): return None alert_time = datetime.now(SAUDI_TZ).strftime("%H:%M:%S KSA") return { "symbol": sym, "pct": best_pct, "msg": ( f"🟢 {sym} +{best_pct:.1f}%\n" f" 💵 سعر التنبيه: {best_row['Close']:.3f}\n" f" 📈 O:{best_row['Open']:.3f} C:{best_row['Close']:.3f}" f" H:{best_row['High']:.3f} L:{best_row['Low']:.3f}\n" f" ⏱ الشمعة: {ts_str} | ⏰ وقت التنبيه: {alert_time}\n" f" 💧 Vol 24H: {_fmt_vol(vol)}" ) } def strat_pump_live(sym, df, dedup: bool = False): """Current candle opened ≥15% above previous close.""" prev, curr = df.iloc[-2], df.iloc[-1] if not prev["Close"] or prev["Close"] == 0: return None pct = (curr["Open"] - prev["Close"]) / prev["Close"] * 100 if pct < 15: return None vol = _vol_24h(df) if vol < VOL_MIN: return None ts_str = df.index[-1].strftime("%H:%M ET") if dedup and not _is_new_signal(sym, f"live@{ts_str}"): return None alert_time = datetime.now(SAUDI_TZ).strftime("%H:%M:%S KSA") return { "symbol": sym, "pct": pct, "msg": ( f"⚡ {sym} +{pct:.1f}%\n" f" 💵 السعر الحالي: {curr['Close']:.3f}\n" f" 📊 Prev C:{prev['Close']:.3f} → Open:{curr['Open']:.3f}\n" f" ⏱ الشمعة: {ts_str} | ⏰ وقت التنبيه: {alert_time}\n" f" 💧 Vol 24H: {_fmt_vol(vol)}" ) } def strat_pump_pm(sym, df, dedup: bool = False): """ Best 15-min pre-market candle of TODAY that moved ≥10% open→close. Lower threshold (10%) because pre-market moves differently. """ today = _now_et().date() mask = [ts.date() == today for ts in df.index] today_df = df[mask] if today_df.empty: return None best_pct = 9.999 best_row = None best_ts = None for ts, row in today_df.iterrows(): if not row["Open"] or row["Open"] == 0: continue pct = (row["Close"] - row["Open"]) / row["Open"] * 100 if pct > best_pct: best_pct = pct best_row = row best_ts = ts if best_row is None: return None vol = _vol_24h(df) if vol < VOL_MIN: return None ts_str = best_ts.strftime("%H:%M ET") if dedup and not _is_new_signal(sym, f"pm@{ts_str}"): return None alert_time = datetime.now(SAUDI_TZ).strftime("%H:%M:%S KSA") return { "symbol": sym, "pct": best_pct, "msg": ( f"🌅 {sym} +{best_pct:.1f}%\n" f" 💵 سعر Pre-market: {best_row['Close']:.3f}\n" f" 📈 O:{best_row['Open']:.3f} C:{best_row['Close']:.3f}" f" H:{best_row['High']:.3f} L:{best_row['Low']:.3f}\n" f" ⏱ الشمعة: {ts_str} PM | ⏰ وقت التنبيه: {alert_time}\n" f" 💧 Vol PM: {_fmt_vol(vol)}" ) } # (strat_fn, stocks_name, label, is_premarket) STRATEGIES = { "pump_sc": (strat_pump_closed, "pump_stocks", "🚀 PUMP Closed 15M — Small Caps", False), "live_sc": (strat_pump_live, "pump_stocks", "⚡ PUMP LIVE 15M — Small Caps", False), "pump_sp": (strat_pump_closed, "sp500_stocks", "🚀 PUMP Closed 15M — S&P 500", False), "live_sp": (strat_pump_live, "sp500_stocks", "⚡ PUMP LIVE 15M — S&P 500", False), "pm_sc": (strat_pump_pm, "pump_stocks", "🌅 Pre-market PUMP 15M — Small Caps", True), "pm_sp": (strat_pump_pm, "sp500_stocks", "🌅 Pre-market PUMP 15M — S&P 500", True), } # ───────────────────────────────────────────────────────────────────────────── # /test command — single stock inspection # ───────────────────────────────────────────────────────────────────────────── def cmd_test(sym, cid): sym = sym.upper().strip() send(f"🔬 جاري جلب {sym}…", cid) t0 = time.time() data = fetch_batch([sym]) elapsed = time.time() - t0 if sym not in data: send(f"❌ لا توجد بيانات لـ {sym}\n" f"(قد يكون الرمز خاطئاً أو السوق مغلق أو السهم مُسرَّح)\n" f"وقت الجلب: {elapsed:.1f}ث", cid) return df = data[sym] lines = [f"📊 {sym} — {len(df)} شمعة 15د (market hours)\n" f"وقت الجلب: {elapsed:.1f}ث\n{'─'*32}"] for i in range(-min(4, len(df)), 0): row = df.iloc[i] ts = df.index[i].strftime("%m/%d %H:%M ET") pct = (row["Close"] - row["Open"]) / row["Open"] * 100 if row["Open"] else 0 arrow = "🟢" if pct >= 0 else "🔴" lines.append( f"{arrow} {ts}\n" f" O: {row['Open']:.4f} C: {row['Close']:.4f}\n" f" H: {row['High']:.4f} L: {row['Low']:.4f}\n" f" Chg: {pct:+.2f}% Vol: {int(row['Volume']):,}" ) lines.append("─" * 32) if len(df) >= 2: pct_c = (df.iloc[-2]["Close"] - df.iloc[-2]["Open"]) / df.iloc[-2]["Open"] * 100 pct_g = (df.iloc[-1]["Open"] - df.iloc[-2]["Close"]) / df.iloc[-2]["Close"] * 100 lines.append(f"📌 آخر شمعة مغلقة: {pct_c:+.2f}% {'✅ ≥15%' if pct_c >= 15 else '❌ <15%'}") lines.append(f"📌 Gap الشمعة الحالية: {pct_g:+.2f}% {'✅ ≥15%' if pct_g >= 15 else '❌ <15%'}") send("\n".join(lines), cid) # ───────────────────────────────────────────────────────────────────────────── # Scan engine — batch download, progress every batch # ───────────────────────────────────────────────────────────────────────────── _scan_lock = threading.Lock() def run_scan_pm(strat_fn, stocks, label, cid): """Like run_scan but fetches pre-market data via fetch_batch_pm.""" if not _scan_lock.acquire(blocking=False): send("⚠️ فحص آخر يعمل، انتظر حتى ينتهي.", cid) return try: total = len(stocks) found = [] total_ok = 0 total_fail = 0 start = time.time() batches = [stocks[i:i+BATCH] for i in range(0, total, BATCH)] nb = len(batches) msg_id = send_id( f"🌅 Pre-market فحص: {label}\n" f"{pbar(0, total)}\n" f"0/{total} | ✅ 0 نجح | ❌ 0 فشل | 🎯 0", cid ) for bi, batch in enumerate(batches): batch_data = fetch_batch_pm(batch) ok = len(batch_data) fail = len(batch) - ok total_ok += ok total_fail += fail for sym, df in batch_data.items(): result = strat_fn(sym, df, False) if result: found.append(result) done = min((bi + 1) * BATCH, total) elapsed = int(time.time() - start) txt = ( f"🌅 جاري: {label}\n" f"{pbar(done, total)}\n" f"{done}/{total} سهم | batch {bi+1}/{nb}\n" f"✅ {total_ok} نجح | ❌ {total_fail} فشل | 🎯 {len(found)}\n" f"⏱ {elapsed}ث مضت" ) if msg_id: edit(cid, msg_id, txt) elapsed = int(time.time() - start) stats_line = f"✅ {total_ok} نجح | ❌ {total_fail} فشل" if msg_id: edit(cid, msg_id, f"✅ اكتمل: {label}\n" f"{pbar(total, total)}\n" f"{total}/{total} سهم | 🎯 {len(found)} إشارة | ⏱ {elapsed}ث\n" f"{stats_line}") now_s = datetime.now(SAUDI_TZ).strftime("%H:%M KSA") if found: found.sort(key=lambda x: x["pct"], reverse=True) hdr = ( f"🌅 {label} — {len(found)} إشارة ({now_s})\n" f"{stats_line} | ⏱ {elapsed}ث\n" f"{'─'*30}\n" ) send(hdr + "\n\n".join(r["msg"] for r in found), cid) else: send(f"🔍 لا إشارات Pre-market ≥10% — {label} ({now_s})\n{stats_line} | ⏱ {elapsed}ث", cid) return len(found) finally: _scan_lock.release() def run_scan(strat_fn, stocks, label, cid): if not _scan_lock.acquire(blocking=False): send("⚠️ فحص آخر يعمل، انتظر حتى ينتهي.", cid) return try: total = len(stocks) found = [] total_ok = 0 total_fail = 0 start = time.time() batches = [stocks[i:i+BATCH] for i in range(0, total, BATCH)] nb = len(batches) msg_id = send_id( f"🔍 بدأ الفحص: {label}\n" f"{pbar(0, total)}\n" f"0/{total} | ✅ 0 نجح | ❌ 0 فشل | 🎯 0", cid ) for bi, batch in enumerate(batches): batch_data = fetch_batch(batch) ok = len(batch_data) fail = len(batch) - ok total_ok += ok total_fail += fail for sym, df in batch_data.items(): result = strat_fn(sym, df, False) # manual scan — no dedup if result: found.append(result) done = min((bi + 1) * BATCH, total) elapsed = int(time.time() - start) txt = ( f"🔍 جاري: {label}\n" f"{pbar(done, total)}\n" f"{done}/{total} سهم | batch {bi+1}/{nb}\n" f"✅ {total_ok} نجح | ❌ {total_fail} فشل | 🎯 {len(found)}\n" f"⏱ {elapsed}ث مضت" ) if msg_id: edit(cid, msg_id, txt) elapsed = int(time.time() - start) stats_line = f"✅ {total_ok} نجح | ❌ {total_fail} فشل" if msg_id: edit(cid, msg_id, f"✅ اكتمل: {label}\n" f"{pbar(total, total)}\n" f"{total}/{total} سهم | 🎯 {len(found)} إشارة | ⏱ {elapsed}ث\n" f"{stats_line}") now_s = datetime.now(SAUDI_TZ).strftime("%H:%M KSA") if found: found.sort(key=lambda x: x["pct"], reverse=True) hdr = ( f"🔥 {label} — {len(found)} إشارة ({now_s})\n" f"{stats_line} | ⏱ {elapsed}ث\n" f"{'─'*30}\n" ) send(hdr + "\n\n".join(r["msg"] for r in found), cid) else: sample = list(fetch_batch([stocks[0]]).values()) smsg = ( f"🔍 لا إشارات ≥15% — {label} ({now_s})\n" f"{stats_line} | ⏱ {elapsed}ث\n\n" ) if sample: df = sample[0] sym = stocks[0] smsg += ( f"📋 عينة تحقق ({sym}):\n" f" {_bar_line(df, -2)} ← آخر مغلقة\n" f" {_bar_line(df, -1)} ← الحالية\n\n" f"ℹ️ جرب /test SYMBOL للتحقق من سهم بعينه" ) send(smsg, cid) return len(found) finally: _scan_lock.release() # ───────────────────────────────────────────────────────────────────────────── # Stocks # ───────────────────────────────────────────────────────────────────────────── sp500_stocks = [ "NVDA","AAPL","GOOGL","GOOG","MSFT","AMZN","AVGO","META","TSLA","LLY", "BRK-B","MU","WMT","JPM","AMD","XOM","ORCL","V","JNJ","INTC", "CSCO","MA","COST","CAT","ABBV","BAC","LRCX","CVX","ARM","UNH","AMAT", "NFLX","GE","KO","PG","MS","PLTR","HD","GS","MRK","PM","IBM","TXN", "DELL","KLAC","WFC","RTX","LIN","MRVL","QCOM","C","PANW", "AXP","MCD","ADI","ANET","PEP","TMUS","VZ","AMGN","APP","NEE", "TJX","TMO","DIS","CRWD","APH","BA","BLK","UNP","GILD","ABT", "T","DE","SCHW","ETN","GLW","CRM","ISRG","PFE","UBER","IBKR", "COP","SHOP","BX","PLD","HON","DHR","BKNG","CB","SPGI","CVS","PDD", "LMT","MO","PGR","LOW","SYK","BMY","NOW","VRT","VRTX","COF","PH", "ACN","SBUX","EQIX","NEM","FTNT","MDT","SO","PWR","CDNS","MAR","ADBE", "TT","HWM","DUK","GD","CME","MCK","ADP","UPS","CEG","PNC","FCX", "AMT","ELV","CMI","SNPS","WM","WMB","JCI","MNST","CSX","USB","CMCSA", "KKR","DDOG","HCA","SLB","MELI","INTU","ABNB","MMM","ICE","MDLZ", "FDX","MCO","HLT","EMR","NOC","CI","MPC","VLO","SHW","RCL","ORLY", "NXPI","HOOD","GM","ROST","APO","PSX","EOG","CVNA","MPWR","ITW", "ECL","BSX","CTAS","CL","KMI","NSC","AEP","CRH","AON","TDG", "DASH","SPG","MSI","URI","REGN","WBD","DLR","HPE","RSG", "TRV","NKE","APD","BKR","TEL","PCAR","GWW","TFC","AFL","SRE","F","D", "NUE","LHX","ALL","O","TRGP","OXY","KEYS","TER","CARR","TGT","OKE", "AJG","MET","PSA","FAST","COR","DAL","AME","CTVA","AZO","DVN", "EA","ETR","ODFL","VST","ROK","EW","NDAQ","XEL","EBAY","ADSK","CAH", "MCHP","FITB","EXC","ON","GRMN","MSCI","STT","IDXX","WAB","MSTR", "HUM","BDX","YUM","KDP","DHI","CCI","AMP","VTR","ALNY", "COIN","AIG","TTWO","PEG","KR","AXON","ED","ADM","STLD","CBRE", "CCL","PCG","CMG","HSY","LYV","IRM","WEC","VMC","SYY", "PYPL","PRU","HIG","PAYX","WAT","WDAY","MLM","UAL", "EQT","ROP","HBAN","ZTS","LVS","NTAP","KMB","HAL","MTB","ACGL", "CNC","EXR","IQV","DTE","AEE","EL","FSLR","VICI","RJF","CBOE", "GEHC","FISV","DOV","BIIB","CPRT","RMD","ATO","IR","EIX","VEEV", "DXCM","CNP","ARES","EXPE","NRG","CFG","OTIS","AVB","PPL", "TPL","JBHT","FE","VRSN","KHC","ES","FICO","XYL", "EQR","WRB","PPG","CTSH","HUBB","SMCI","WTW","BG","DOW","RF", "AWK","STZ","WSM","SYF","VRSK","KEY","HPQ","MTD","CHD","DG","PFG", "TROW","CPAY","DRI","ROL","PHM","NI","CMS","LEN","WST","DGX", "L","SBAC","RL","LH","AKAM","CHRW","OMC","FIS","ZS", "EXPD","DLTR","LYB","STE","EFX","INCY","INSM","LUV","TSN","ULTA","BRO", "PKG","SNA","DD","MRNA","LNT","IFF","VTRS","ESS","ALB", "CHTR","GPN","INVH","IP","GIS","WY","AMCR","BR","CF","CDW","ZBH", "HST","NVR","KIM","BEN","MAA","IEX","TXT","GEN","PTC","LDOS", "TSCO","GNRC","CPT","BBY","DECK","APTV","REG","BALL", "MAS","DOC","LULU","GPC","EG","TYL","HRL","APA", "AIZ","UDR","MKC","TRMB","PNW","GL","DVA","SWK","MGM","IVZ", "ALGN","HAS","AVY","PNR","HII","CLX","ALLE","GDDY","SWKS","ZBRA", "IT","RVTY","WYNN","FRT","PODD","ERIE","AES","DPZ","BAX","BXP", "TTD","FDS","JKHY","ARE","HSIC","UHS","CRL","NCLH","TECH","BLDR","AOS", "TAP","MOS","POOL","CPB","CAG" ] pump_stocks = [ "AACG", "AAME", "ABTS", "ACAA", "ACAAU", "ACAAW", "ACCL", "ACCS", "ACET", "ACFN", "ACNB", "ACNT", "ACON", "ACONW", "ACR", "ACTU", "ACU", "ACXP", "ADGM", "ADIL", "ADTX", "ADV", "ADVB", "ADXN", "AEHL", "AEI", "AEMD", "AENT", "AENTW", "AERT", "AERTW", "AEYE", "AFBI", "AFJK", "AFJKR", "AFJKU", "AFRI", "AFRIW", "AFYA", "AGCC", "AGIG", "AGL", "AGMH", "AGPU", "AGRZ", "AHMA", "AHT", "AIDX", "AIFA", "AIHS", "AIM", "AIMD", "AIMDW", "AIOS", "AIRE", "AIRI", "AIRT", "AIRTP", "AIXI", "AKA", "AKAN", "AKTX", "ALAR", "ALCO", "ALF", "ALFUU", "ALFUW", "ALGS", "ALIS", "ALISR", "ALISU", "ALOT", "ALP", "ALX", "ALZN", "AMBO", "AMCI", "AMS", "AMSS", "AMST", "ANGH", "ANGHW", "ANNA", "ANNAW", "ANTA", "ANY", "APAC", "APACR", "APACU", "APLM", "APM", "APRE", "APT", "APUR", "APURR", "APVO", "APWC", "AQB", "AQMS", "ARBB", "ARBK", "ARCI", "ARCIU", "ARCIW", "ARCL", "ARCLR", "ARCLU", "ARCLW", "ARKR", "ARL", "ARQQ", "ARQQW", "ARTL", "ARTNA", "ARTV", "ARTW", "ASBP", "ASBPW", "ASIC", "ASMB", "ASPC", "ASPCR", "ASPCU", "ASPS", "ASPSW", "ASPSZ", "ASR", "ASRT", "ASTC", "ASTI", "ATER", "ATGL", "ATHE", "ATHR", "ATLC", "ATLCP", "ATLO", "ATNI", "ATOS", "ATPC", "ATRA", "ATXG", "AUBN", "AUC", "AURE", "AUST", "AUUD", "AVBH", "AWX", "AXIL", "AXR", "AYTU", "AZI", "BAFN", "BANL", "BAOS", "BARK", "BAYA", "BAYAR", "BAYAU", "BBGI", "BBLG", "BBLGW", "BCAB", "BCDA", "BCG", "BCGWW", "BCTX", "BCTXL", "BCTXZ", "BDL", "BDMD", "BDMDW", "BDRX", "BDSX", "BEBE", "BESS", "BETR", "BETRW", "BFRI", "BFRIW", "BGDE", "BGI", "BGLC", "BGMS", "BGSF", "BH", "BHAV", "BHAVR", "BHAVU", "BHM", "BIAF", "BIAFW", "BIDWU", "BIRD", "BIVI", "BIVIW", "BIYA", "BJDX", "BKHA", "BKHAR", "BKHAU", "BKTI", "BLRX", "BMGL", "BMHL", "BMRA", "BNAI", "BNAIW", "BNBX", "BNKK", "BNR", "BNRG", "BNZI", "BNZIW", "BODI", "BOLT", "BON", "BOSC", "BOTJ", "BOXL", "BPACR", "BPRN", "BQ", "BREZ", "BREZR", "BRFH", "BRID", "BRLS", "BRLSW", "BRN", "BRUN", "BRUNW", "BSAA", "BSAAR", "BSAAU", "BSBK", "BSET", "BSVN", "BTBD", "BTCT", "BTOG", "BUDA", "BUUU", "BVFL", "BWFG", "BXC", "BYAH", "BYFC", "CAAS", "CABO", "CABR", "CACC", "CALC", "CANF", "CAPN", "CAPNR", "CAPNU", "CAPS", "CARL", "CBFV", "CBK", "CBNA", "CBRS", "CCAQ", "CCAQU", "CCAQW", "CCEC", "CCEL", "CCHH", "CCM", "CCTG", "CDIO", "CDIOW", "CDT", "CDTG", "CDTTW", "CELZ", "CENN", "CETX", "CFBK", "CFFI", "CHAI", "CHAR", "CHARR", "CHARU", "CHCI", "CHMG", "CHNR", "CHPG", "CHPGR", "CHPGU", "CHR", "CHRN", "CHSN", "CIIT", "CISS", "CIX", "CJMB", "CKX", "CLIK", "CLIR", "CLRB", "CLRO", "CLST", "CLWT", "CMCM", "CMCT", "CMDB", "CMMB", "CMND", "CMT", "CMTV", "CNET", "CNEY", "CNF", "CNSP", "COAG", "COCP", "CODA", "CODX", "COE", "COHN", "COKE", "COLA", "COLAR", "COLAU", "COOK", "COOT", "COOTW", "COSO", "CPBI", "CPHC", "CPIX", "CPSS", "CRAI", "CRE", "CREX", "CRMT", "CRT", "CRVO", "CRWS", "CSBR", "CSPI", "CTEV", "CTNT", "CTRN", "CTW", "CUE", "CULP", "CUPR", "CURX", "CVCO", "CVEO", "CVKD", "CVM", "CVR", "CVV", "CWD", "CXIIU", "CYAB", "CYCN", "CYCU", "CYCUW", "CZFS", "CZWI", "DAC", "DAIC", "DAICW", "DAIO", "DAVE", "DAVEW", "DCBO", "DCOY", "DDS", "DETX", "DEVS", "DFSC", "DFSCW", "DIT", "DJCO", "DKI", "DLHC", "DLPN", "DLTH", "DLXY", "DOGZ", "DOMH", "DRCT", "DRIO", "DRMA", "DRMAW", "DRUG", "DSS", "DSWL", "DTSQ", "DTSQR", "DTSQU", "DTSS", "DTST", "DUKR", "DUKRW", "DWSN", "DWTX", "DXR", "DXST", "EBMT", "EBON", "ECBK", "ECO", "ECOR", "EDBL", "EDBLW", "EDHL", "EDN", "EDRY", "EDSA", "EDTK", "EDUC", "EEIQ", "EFOI", "EFSI", "EFTY", "EGG", "EHGO", "EHLD", "EJH", "ELA", "ELAB", "ELLO", "ELMD", "ELMT", "ELOG", "ELOX", "ELPW", "ELTK", "ELWT", "EML", "EMPG", "ENGS", "ENVB", "EPSM", "EQS", "ERNA", "ERNAW", "EROC", "ESCA", "ESEA", "ESLAW", "ESP", "ESQ", "ETS", "ETSS", "EUDA", "EUDAW", "EURK", "EURKR", "EURKU", "EVAX", "EVI", "EXOD", "EXOZ", "EXYN", "EXYNW", "EZRA", "FABC", "FAMI", "FATN", "FBGL", "FBIZ", "FBLG", "FC", "FCAP", "FCCO", "FCNCA", "FCNCO", "FCNCP", "FCUV", "FDBC", "FDSB", "FEDU", "FEED", "FEIM", "FENG", "FET", "FGBI", "FGBIP", "FGI", "FGIWW", "FGL", "FGNX", "FGNXP", "FIEE", "FINW", "FKWL", "FLD", "FLDDW", "FLXS", "FLYE", "FMAC", "FMACR", "FMACU", "FMFC", "FNUC", "FNWB", "FNWD", "FOA", "FOFO", "FORTY", "FOXX", "FOXXW", "FRAF", "FRD", "FRGT", "FRMM", "FRSX", "FRTT", "FRVO", "FSBW", "FSEA", "FSHP", "FSHPR", "FSHPU", "FSI", "FSTR", "FTFT", "FTHAU", "FTLF", "FTRK", "FTW", "FUNC", "FUSB", "FVN", "FVNNR", "FVNNU", "FXNC", "GBFH", "GBLI", "GBR", "GCBC", "GCDT", "GCO", "GCTK", "GDEV", "GDEVW", "GDTC", "GELS", "GENK", "GHC", "GHG", "GIGM", "GIPR", "GIPRW", "GITS", "GLE", "GLED", "GLMD", "GLOO", "GLSI", "GLXG", "GMEX", "GMM", "GNLN", "GOVX", "GP", "GRAN", "GRI", "GROW", "GRVY", "GSBC", "GSIW", "GSRVU", "GSUN", "GTIM", "GURE", "GV", "GVH", "GWAV", "GXAI", "GYRO", "HAO", "HAWK", "HBB", "HBCP", "HBIO", "HCAI", "HCHL", "HCTI", "HCWB", "HDRN", "HDRNW", "HFBL", "HHS", "HIFS", "HIHO", "HIND", "HKIT", "HKPD", "HMH", "HMR", "HNNA", "HNVR", "HOUR", "HOV", "HPAI", "HPAIW", "HQI", "HSCS", "HSCSW", "HSPT", "HSPTR", "HSPTU", "HTCO", "HTCR", "HTLM", "HTOO", "HUBC", "HUBCZ", "HUDI", "HUHU", "HUIZ", "HURC", "HWBK", "HWH", "HWKN", "HXHX", "HYFM", "IBAC", "IBACR", "IBG", "IBTA", "ICCC", "ICCM", "ICON", "ICU", "ICUCW", "IDACU", "IDAI", "IESC", "IFBD", "IHT", "IIIV", "ILAG", "IMA", "IMCC", "IMNN", "IMRN", "IMTE", "INAB", "INBK", "INBS", "INBX", "INDO", "INDP", "INEO", "INHD", "INKT", "INLF", "INLX", "INM", "INSE", "INTG", "INTJ", "INTS", "IOR", "IOTR", "IPDN", "IPEX", "IPEXR", "IPEXU", "IPFX", "IPFXU", "IPFXW", "IPM", "IPST", "IPW", "IPWR", "IQST", "IRMD", "ISBA", "ISPC", "ITIC", "IVF", "IXHL", "IZM", "JAGX", "JAKK", "JBDI", "JBSS", "JCSE", "JCTC", "JDZG", "JEM", "JFB", "JFU", "JG", "JILL", "JL", "JLHL", "JOUT", "JRSH", "JTAI", "JVA", "JWEL", "JXG", "JYD", "JYNT", "JZ", "JZXN", "KARO", "KEQU", "KFFB", "KG", "KIDZ", "KIDZW", "KITT", "KITTW", "KLRA", "KLRS", "KMDA", "KMRK", "KORE", "KOSS", "KPLT", "KPRX", "KRKR", "KRRO", "KRT", "KRUS", "KUST", "KVAC", "KVACU", "KVACW", "KWY", "KXIN", "KYNB", "KZIA", "LABT", "LAKE", "LARK", "LBGJ", "LCCC", "LCCCR", "LCCCU", "LCFY", "LCFYW", "LEDS", "LEE", "LESL", "LFS", "LFVN", "LFWD", "LGCY", "LGHL", "LGL", "LGPS", "LHSW", "LINK", "LIQT", "LITB", "LIVE", "LNAI", "LNSR", "LNZA", "LOAN", "LOBO", "LOCL", "LONA", "LPA", "LPCN", "LRE", "LRHC", "LSBK", "LSE", "LSF", "LSTA", "LTRN", "LUCK", "LUCY", "LUCYW", "LUD", "LVLU", "LVO", "LWAY", "LXEH", "LZMH", "MAMK", "MAMO", "MARPS", "MASK", "MAYS", "MB", "MBAI", "MBBC", "MBIO", "MBRX", "MCFT", "MCRB", "MCTA", "MDBH", "MDRR", "MDV", "MDWD", "MED", "MEGL", "MFI", "MG", "MGIH", "MGRT", "MGYR", "MHH", "MI", "MIMI", "MIND", "MITQ", "MKDW", "MKDWW", "MKTW", "MKZR", "MLAB", "MLCI", "MLGO", "MLP", "MMTX", "MMTXU", "MMTXW", "MNDR", "MNPR", "MNSB", "MNSBP", "MNTS", "MNTSW", "MOBI", "MODD", "MOGU", "MOVE", "MPTI", "MRBK", "MRDN", "MRM", "MRNO", "MRNOW", "MSAI", "MSAIW", "MSGM", "MSGY", "MSN", "MSS", "MSW", "MTEK", "MTEKW", "MTEN", "MTEX", "MTNB", "MTR", "MTVA", "MVO", "MXC", "MYFW", "MYND", "MYSE", "MYSEW", "MYSZ", "MYX", "MYXXR", "MYXXU", "MYXXW", "NAAS", "NAII", "NAMM", "NAMMW", "NATH", "NBN", "NBRG", "NBRGR", "NBRGU", "NC", "NCEL", "NCEW", "NCI", "NCPL", "NCPLW", "NCRA", "NCSM", "NCT", "NCTY", "NDLS", "NDRA", "NEGG", "NEN", "NEPH", "NEU", "NEUP", "NEXR", "NEXRW", "NGNE", "NHTC", "NINE", "NIVF", "NIVFW", "NKSH", "NL", "NMP", "NMPAR", "NMPAU", "NMTC", "NODK", "NOEM", "NOEMR", "NOEMU", "NOEMW", "NOMA", "NPK", "NPT", "NRP", "NRT", "NRXS", "NSRX", "NSTS", "NSYS", "NTHI", "NTIC", "NTRB", "NTRBW", "NTRP", "NTWK", "NTZ", "NUTR", "NUTX", "NUWE", "NVEC", "NVNI", "NVNIW", "NVNO", "NVR", "NVVE", "NWFL", "NWPX", "NWTG", "NXB", "NXGL", "NXGLW", "NXPL", "NXTC", "NXTS", "NYC", "OCC", "OCG", "ODC", "OESX", "OFAL", "OFLX", "OGEN", "OLB", "OLOX", "ONCO", "ONEG", "ONFO", "ONFOW", "ONIT", "OPAD", "OPHC", "OPTH", "OPTX", "OPTXW", "OPXS", "OPY", "ORGN", "ORGNW", "ORIQ", "ORIQU", "ORIQW", "ORIS", "ORKT", "OST", "OVBC", "OVLY", "OXBR", "OXBRW", "OZ", "PALO", "PALOU", "PALOW", "PAMT", "PAPL", "PARK", "PASG", "PAVM", "PAVS", "PBFS", "PBHC", "PBM", "PBMWW", "PC", "PCLA", "PCSA", "PCSC", "PDC", "PDEX", "PEBK", "PECE", "PECER", "PECEU", "PECEW", "PED", "PETZ", "PFAI", "PFIS", "PFSA", "PFX", "PGAC", "PGACR", "PGACU", "PHGE", "PHOE", "PIII", "PIIIW", "PKBK", "PKOH", "PLBC", "PLBL", "PLCE", "PLPC", "PLRZ", "PLSM", "PLTS", "PLUR", "PLUT", "PLYX", "PMCB", "PMEC", "PMN", "PMTS", "PN", "PNRG", "POAS", "POCI", "PODC", "POLA", "PPBT", "PPCB", "PPIH", "PPSI", "PRFX", "PRHI", "PRHIZ", "PROV", "PRPO", "PRT", "PRTS", "PSIG", "PSIX", "PSTV", "PTACU", "PTHS", "PTLE", "PTN", "PTNM", "PULM", "PVLA", "PW", "PXED", "PXLW", "PXS", "QCLS", "QETA", "QETAR", "QETAU", "QH", "QNRX", "QNTM", "QRED", "QSEA", "QSEAR", "QSEAU", "QTI", "QTTB", "QUMS", "QUMSR", "QUMSU", "QXL", "RADX", "RAIN", "RAINW", "RAY", "RAYA", "RBCAA", "RBKB", "RBNE", "RCKY", "RCMT", "RDAC", "RDACR", "RDACU", "RDCM", "RDGT", "RDHL", "REA", "REBN", "RECT", "REED", "RENX", "RETO", "REVB", "REVBW", "RFAI", "RFAIR", "RFAIU", "RFAM", "RFAMR", "RFAMU", "RFIL", "RGCO", "RGNT", "RGS", "RHLD", "RIBB", "RIBBR", "RIBBU", "RICK", "RJET", "RKDA", "RLMD", "RLYB", "RM", "RMBI", "RMCF", "RMCO", "RMCOW", "RMSG", "RMSGW", "RNAZ", "ROLR", "RPGL", "RPT", "RRBI", "RTB", "RUBI", "RVSN", "RVSNW", "RYM", "RYOJ", "SAGT", "SAIH", "SAIHW", "SAM", "SAMG", "SBFG", "SBFM", "SBFMW", "SCKT", "SCLX", "SCLXW", "SCNI", "SCOR", "SCPQ", "SCPQU", "SCPQW", "SCYX", "SDEV", "SDHC", "SDOT", "SDST", "SDSTW", "SEAT", "SEATW", "SEB", "SEED", "SEG", "SEGG", "SENEA", "SENEB", "SER", "SFBC", "SFHG", "SFST", "SGA", "SGLY", "SGP", "SGRP", "SHAZ", "SHFS", "SHFSW", "SHIM", "SHPH", "SIF", "SILC", "SILO", "SIMA", "SIMAU", "SIMAW", "SINT", "SKBL", "SKIL", "SKK", "SKLZ", "SKYQ", "SLE", "SLGB", "SLGL", "SLNG", "SLXN", "SLXNW", "SMBC", "SMC", "SMID", "SMJF", "SMSI", "SMTI", "SMX", "SMXWW", "SNES", "SNOA", "SNSE", "SNTG", "SNWV", "SNYR", "SOBR", "SOGP", "SONM", "SORA", "SOTK", "SPCB", "SPEG", "SPEGU", "SPHL", "SPKL", "SPKLU", "SPKLW", "SPPL", "SPRB", "SPRC", "SPTX", "SQFT", "SQFTP", "SQFTW", "SRBK", "SRL", "SRZN", "SRZNW", "SSBI", "SSEA", "SSEAR", "SSEAU", "SSM", "SST", "SSTI", "STAK", "STEM", "STFS", "STG", "STHO", "STI", "STRR", "STRRP", "STRS", "STRT", "STRW", "SUGP", "SUNE", "SUNS", "SVRE", "SVRN", "SWAG", "SWAGW", "SWMR", "SWVL", "SXTC", "SXTP", "SYNX", "TACT", "TANH", "TAOP", "TAOX", "TAVI", "TAVIR", "TAVIU", "TAYD", "TBRG", "TC", "TCBS", "TCGL", "TCI", "TCRT", "TCX", "TDIC", "TDTH", "TGE", "TGL", "THCH", "TIL", "TISI", "TJGC", "TKLF", "TLF", "TLIH", "TMDE", "TNMG", "TOP", "TOPP", "TOPS", "TORO", "TOUR", "TOYO", "TPCS", "TPST", "TRNR", "TRNS", "TRSG", "TRT", "TRUG", "TRVG", "TSAT", "TSBK", "TULP", "TURB", "TVGN", "TVGNW", "TVRD", "TWAV", "TXMD", "TZOO", "UAC", "UBCP", "UBXG", "UCAR", "UCFI", "UCFIW", "UEIC", "UFG", "UFPT", "UG", "UGRO", "UI", "UK", "ULBI", "ULH", "UNB", "UNTY", "UONE", "UONEK", "UP", "UPC", "USEA", "USGO", "USNA", "UTMD", "UTSI", "UUU", "UYSC", "UYSCR", "UYSCU", "UZX", "VABK", "VALU", "VATE", "VBIO", "VCIG", "VEEE", "VEL", "VGAS", "VHC", "VHI", "VIDA", "VIRC", "VIVK", "VIVO", "VIVS", "VLGEA", "VMAR", "VNCE", "VNRX", "VRAX", "VRCA", "VRM", "VRTS", "VS", "VSA", "VSME", "VTAK", "VTVT", "WAFU", "WAI", "WATT", "WAVE", "WBUY", "WBX", "WCT", "WENC", "WEYS", "WFCF", "WFF", "WGRX", "WHG", "WHLR", "WHLRD", "WHLRL", "WHLRP", "WILC", "WINA", "WKEY", "WKHS", "WKSP", "WLDS", "WLDSW", "WLFC", "WNW", "WOK", "WRLC", "WSBK", "WSHP", "WSTN", "WSTNR", "WSTNU", "WTF", "WTG", "WTGUR", "WTGUU", "WTM", "WTO", "WVVI", "WVVIP", "WW", "WXM", "WYY", "XBIO", "XBP", "XBPEW", "XCUR", "XELB", "XHLD", "XLO", "XOMA", "XOMAO", "XOMAP", "XOS", "XRTX", "XTLB", "XWEL", "XXII", "YAAS", "YB", "YCY", "YDDL", "YDES", "YDESW", "YDKG", "YHGJ", "YHNA", "YHNAR", "YHNAU", "YI", "YIBO", "YJ", "YMAT", "YMT", "YQ", "YSXT", "YYAI", "ZBAO", "ZCMD", "ZEO", "ZEOWW", "ZEPP", "ZJK", "ZNB", "ZOOZ", "ZOOZW", "ZSQR", "ZSTK", "ZYBT", "SPCL", "UBXG", "EDHL", "DSY", "FRD", "SPCX", "AXTU", "AXTX", "AKAN", "ASTN", "CIFU", "CIFG", "SKIL", "ARMG", "POEL", "CRWU", "CWVX", "CRWG", "RNAC", "WPAC", "STXX", "AMKL", "ALBG", "KEEX", "STXL", "STXU", "NVTX", "VELL", "FLYT", "ASUP", "ASTY", "MNTS", "ASTX", "SATG", "ASTC", "LUNL", "CORD", "DXYZ", "QH", "AIBZ", "SDOT", "RDWU", "PLU", "HSPT", "YSS", "VELO", "FAC", "PCLA", "FLY", "RKLX", "ADBU", "OMER", "TVGN", "BVC" ] # ───────────────────────────────────────────────────────────────────────────── # Auto-scheduler — fires every 15-min candle close during market hours # ───────────────────────────────────────────────────────────────────────────── _auto_enabled = True _auto_lock = threading.Lock() _ET = ZoneInfo("America/New_York") def _now_et(): return datetime.now(_ET) def _is_market_hours(): now = _now_et() if now.weekday() >= 5: # Saturday / Sunday return False return dtime(9, 30) <= now.time() < dtime(16, 0) def _is_premarket(): now = _now_et() if now.weekday() >= 5: # Saturday / Sunday return False return dtime(4, 0) <= now.time() < dtime(9, 30) def _secs_to_next_close(): """Seconds until next :00/:15/:30/:45 boundary.""" now = _now_et() m = now.minute s = now.second + now.microsecond / 1_000_000 wait = ((m // 15) + 1) * 15 * 60 - m * 60 - s if wait <= 0: wait += 900 return wait def _fetch_all(stock_list): """Fetch all stocks in batches; return (results_dict, ok_count, fail_count).""" results = {} total_ok = 0 total_fail = 0 for i in range(0, len(stock_list), BATCH): batch = stock_list[i : i + BATCH] bd = fetch_batch(batch) results.update(bd) total_ok += len(bd) total_fail += len(batch) - len(bd) return results, total_ok, total_fail def _fetch_all_pm(stock_list): """Fetch all stocks (pre-market data) in batches; return (results_dict, ok, fail).""" results = {} total_ok = 0 total_fail = 0 for i in range(0, len(stock_list), BATCH): batch = stock_list[i : i + BATCH] bd = fetch_batch_pm(batch) results.update(bd) total_ok += len(bd) total_fail += len(batch) - len(bd) return results, total_ok, total_fail def _send_strat_alert(strat_fn, results, label, now_s, stats): """Run one strategy on pre-fetched data and send a message only if signals found.""" found = [] for sym, df in results.items(): r = strat_fn(sym, df, True) # auto scan — dedup ON if r: found.append(r) if not found: return found.sort(key=lambda x: x["pct"], reverse=True) emoji = "🚀" if "Closed" in label else "⚡" hdr = ( f"{emoji} {label}\n" f"🕐 {now_s} | {stats} | 🎯 {len(found)} إشارة\n" f"{'─' * 32}\n" ) send(hdr + "\n\n".join(r["msg"] for r in found)) def auto_scan_all(): if not _auto_lock.acquire(blocking=False): print("⚠️ Auto scan skipped — previous still running") return try: now_s = datetime.now(SAUDI_TZ).strftime("%H:%M KSA") print(f"🔔 Auto scan @ {now_s}") # ── Small Caps ──────────────────────────────────────────────────────── sc_data, ok, fail = _fetch_all(pump_stocks) stats_sc = f"✅ {ok} | ❌ {fail}" _send_strat_alert(strat_pump_closed, sc_data, "PUMP Closed 15M — Small Caps", now_s, stats_sc) _send_strat_alert(strat_pump_live, sc_data, "PUMP LIVE 15M — Small Caps", now_s, stats_sc) # ── S&P 500 ─────────────────────────────────────────────────────────── sp_data, ok, fail = _fetch_all(sp500_stocks) stats_sp = f"✅ {ok} | ❌ {fail}" _send_strat_alert(strat_pump_closed, sp_data, "PUMP Closed 15M — S&P 500", now_s, stats_sp) _send_strat_alert(strat_pump_live, sp_data, "PUMP LIVE 15M — S&P 500", now_s, stats_sp) finally: _auto_lock.release() _pm_auto_lock = threading.Lock() def auto_scan_pm(): """Auto pre-market scan — runs every 15-min boundary during 4:00–9:30 ET.""" if not _pm_auto_lock.acquire(blocking=False): print("⚠️ PM auto scan skipped — previous still running") return try: now_s = datetime.now(SAUDI_TZ).strftime("%H:%M KSA") print(f"🌅 Pre-market auto scan @ {now_s}") sc_data, ok, fail = _fetch_all_pm(pump_stocks) stats_sc = f"✅ {ok} | ❌ {fail}" _send_strat_alert(strat_pump_pm, sc_data, "Pre-market PUMP 15M — Small Caps", now_s, stats_sc) sp_data, ok, fail = _fetch_all_pm(sp500_stocks) stats_sp = f"✅ {ok} | ❌ {fail}" _send_strat_alert(strat_pump_pm, sp_data, "Pre-market PUMP 15M — S&P 500", now_s, stats_sp) finally: _pm_auto_lock.release() def candle_scheduler(): print("⏰ Candle scheduler started (fires on :00/:15/:30/:45 ET, market hours only)") while True: wait = _secs_to_next_close() + 10 # +10s buffer for data propagation time.sleep(wait) if _auto_enabled and _is_market_hours(): threading.Thread(target=auto_scan_all, daemon=True).start() elif _auto_enabled and _is_premarket(): threading.Thread(target=auto_scan_pm, daemon=True).start() # ───────────────────────────────────────────────────────────────────────────── # Telegram polling # ───────────────────────────────────────────────────────────────────────────── def poll(): global _auto_enabled last_id = None while True: try: params = {"timeout": 5} if last_id is not None: params["offset"] = last_id + 1 res = _tg.get( f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates", params=params, timeout=15 ).json() for u in res.get("result", []): last_id = u["update_id"] if "message" in u: text = u["message"].get("text", "").strip() cid = str(u["message"]["chat"]["id"]) if text in ("/start", "/help"): mh = "✅ مفتوح" if _is_market_hours() else ("🌅 Pre-market" if _is_premarket() else "❌ مغلق") nx_min = int(_secs_to_next_close() / 60) nx_sec = int(_secs_to_next_close() % 60) send( "👋 بوت مراقبة الأسهم — PUMP 15M\n\n" "🤖 الفحص التلقائي يعمل كل إغلاق شمعة 15 دقيقة\n" f" السوق: {mh} | الشمعة القادمة: {nx_min}د {nx_sec}ث\n\n" "الأوامر:\n" "/today — أسهم pumped اليوم ≥15% (market hours)\n" "/premarket — أسهم pumped اليوم ≥10% (pre-market)\n" "/update — فحص يدوي باختيار الاستراتيجية\n" "/auto — تفعيل/إيقاف الفحص التلقائي\n" "/status — حالة البوت والسوق الآن\n" "/test SYMBOL — فحص سهم مباشر\n\n" "📊 Market Hours: 15د (9:30–16:00 ET) — شرط ≥15%\n" "🌅 Pre-market: 15د (4:00–9:30 ET) — شرط ≥10%\n" "💧 يعرض السيولة لكل إشارة", cid ) elif text == "/status": if _is_market_hours(): mh = "✅ مفتوح الآن (Market Hours)" elif _is_premarket(): mh = "🌅 Pre-market الآن (4:00–9:30 ET)" else: mh = "❌ مغلق الآن" auto = "🟢 تلقائي: مفعّل" if _auto_enabled else "🔴 تلقائي: موقوف" nx = _secs_to_next_close() now_s = datetime.now(SAUDI_TZ).strftime("%H:%M:%S KSA") now_et_s = _now_et().strftime("%H:%M:%S ET") send( f"📊 حالة البوت — {now_s}\n" f" الوقت ET: {now_et_s}\n" f" السوق: {mh}\n" f" {auto}\n" f" الشمعة القادمة تغلق في: {int(nx//60)}د {int(nx%60)}ث\n\n" f"📋 قوائم الأسهم:\n" f" Small Caps: {len(pump_stocks):,} سهم\n" f" S&P 500: {len(sp500_stocks):,} سهم\n\n" f"⏰ الجدول التلقائي:\n" f" 🌅 Pre-market: 4:00–9:30 ET (≥10%)\n" f" 📊 Market Hours: 9:30–16:00 ET (≥15%)", cid ) elif text == "/auto": _auto_enabled = not _auto_enabled st = "🟢 مفعّل — سيرسل تنبيهات تلقائياً عند كل إغلاق شمعة 15د" \ if _auto_enabled else \ "🔴 موقوف — استخدم /update للفحص اليدوي" send(f"الفحص التلقائي: {st}", cid) elif text == "/today": today_s = _now_et().strftime("%a %b %d") send( f"📅 {today_s} — جاري فحص كل الأسهم\n" f" Small Caps: {len(pump_stocks):,} | S&P 500: {len(sp500_stocks):,}\n" f" يبحث عن أي شمعة ≥15% منذ فتح السوق...", cid ) def _today_scan(cid=cid, today_s=today_s): sc = run_scan(strat_pump_closed, pump_stocks, f"📅 Pumps اليوم ({today_s}) — Small Caps", cid) or 0 sp = run_scan(strat_pump_closed, sp500_stocks, f"📅 Pumps اليوم ({today_s}) — S&P 500", cid) or 0 if sc + sp == 0: send("📭 لا توجد أسهم pumped ≥15% اليوم حتى الآن.", cid) threading.Thread(target=_today_scan, daemon=True).start() elif text in ("/premarket", "/pm"): today_s = _now_et().strftime("%a %b %d") send( f"🌅 Pre-market {today_s} — جاري فحص كل الأسهم\n" f" Small Caps: {len(pump_stocks):,} | S&P 500: {len(sp500_stocks):,}\n" f" يبحث عن أي شمعة ≥10% في الـ Pre-market (4:00–9:30 ET)...", cid ) def _pm_scan(cid=cid, today_s=today_s): _scan_lock_pm = threading.Lock() found_total = 0 # Small Caps PM total_sc = len(pump_stocks) batches_sc = [pump_stocks[i:i+BATCH] for i in range(0, total_sc, BATCH)] found_sc = [] ok_sc = fail_sc = 0 msg_id = send_id( f"🌅 Pre-market فحص — Small Caps\n{pbar(0, total_sc)}\n0/{total_sc}", cid ) import time as _t; start = _t.time() for bi, batch in enumerate(batches_sc): bd = fetch_batch_pm(batch) ok_sc += len(bd); fail_sc += len(batch) - len(bd) for sym, df in bd.items(): r = strat_pump_pm(sym, df, False) if r: found_sc.append(r) done = min((bi+1)*BATCH, total_sc) if msg_id: edit(cid, msg_id, f"🌅 Pre-market — Small Caps\n{pbar(done,total_sc)}\n" f"{done}/{total_sc} ✅{ok_sc} ❌{fail_sc} 🎯{len(found_sc)}") elapsed = int(_t.time()-start) if found_sc: found_sc.sort(key=lambda x: x["pct"], reverse=True) hdr = (f"🌅 Pre-market Pumps ({today_s}) — Small Caps\n" f"✅{ok_sc} | ❌{fail_sc} | ⏱{elapsed}ث\n{'─'*28}\n") send(hdr + "\n\n".join(r["msg"] for r in found_sc), cid) else: send(f"🔍 لا إشارات Pre-market ≥10% — Small Caps ({today_s})", cid) found_total += len(found_sc) # S&P 500 PM total_sp = len(sp500_stocks) batches_sp = [sp500_stocks[i:i+BATCH] for i in range(0, total_sp, BATCH)] found_sp = [] ok_sp = fail_sp = 0 msg_id2 = send_id( f"🌅 Pre-market فحص — S&P 500\n{pbar(0, total_sp)}\n0/{total_sp}", cid ) start = _t.time() for bi, batch in enumerate(batches_sp): bd = fetch_batch_pm(batch) ok_sp += len(bd); fail_sp += len(batch) - len(bd) for sym, df in bd.items(): r = strat_pump_pm(sym, df, False) if r: found_sp.append(r) done = min((bi+1)*BATCH, total_sp) if msg_id2: edit(cid, msg_id2, f"🌅 Pre-market — S&P 500\n{pbar(done,total_sp)}\n" f"{done}/{total_sp} ✅{ok_sp} ❌{fail_sp} 🎯{len(found_sp)}") elapsed = int(_t.time()-start) if found_sp: found_sp.sort(key=lambda x: x["pct"], reverse=True) hdr = (f"🌅 Pre-market Pumps ({today_s}) — S&P 500\n" f"✅{ok_sp} | ❌{fail_sp} | ⏱{elapsed}ث\n{'─'*28}\n") send(hdr + "\n\n".join(r["msg"] for r in found_sp), cid) else: send(f"🔍 لا إشارات Pre-market ≥10% — S&P 500 ({today_s})", cid) found_total += len(found_sp) if found_total == 0: send("📭 لا توجد أسهم pumped ≥10% في الـ Pre-market اليوم.", cid) threading.Thread(target=_pm_scan, daemon=True).start() elif text == "/update": keyboard( "📊 اختر الاستراتيجية:", [ [{"text": "🚀 PUMP Closed — Small Caps", "callback_data": "pump_sc"}], [{"text": "⚡ PUMP LIVE — Small Caps", "callback_data": "live_sc"}], [{"text": "🚀 PUMP Closed — S&P 500", "callback_data": "pump_sp"}], [{"text": "⚡ PUMP LIVE — S&P 500", "callback_data": "live_sp"}], [{"text": "🌅 Pre-market PUMP — Small Caps", "callback_data": "pm_sc"}], [{"text": "🌅 Pre-market PUMP — S&P 500", "callback_data": "pm_sp"}], ], cid ) elif text.startswith("/test"): parts = text.split() sym = parts[1] if len(parts) >= 2 else None if sym: threading.Thread(target=cmd_test, args=(sym, cid), daemon=True).start() else: send("أرسل رمز السهم مع الأمر، مثال:\n/test NVDA", cid) elif "callback_query" in u: cq = u["callback_query"] answer_cb(cq["id"]) data = cq.get("data") cid = str(cq["message"]["chat"]["id"]) if data in STRATEGIES: strat_fn_name, stocks_name, label, is_pm = STRATEGIES[data] stocks = sp500_stocks if stocks_name == "sp500_stocks" else pump_stocks scan_fn = run_scan_pm if is_pm else run_scan threading.Thread( target=scan_fn, args=(strat_fn_name, stocks, label, cid), daemon=True ).start() except Exception as e: print(f"Poll error: {e}") time.sleep(3) # ───────────────────────────────────────────────────────────────────────────── # Flask — Health-check server (must respond fast, no external deps) # ───────────────────────────────────────────────────────────────────────────── app = Flask(__name__) app.logger.disabled = True # silence Flask access log noise @app.route("/ping") def ping(): """Cron-job health check — returns HTTP 200 in < 1 ms, no external deps.""" return "OK", 200 @app.route("/") def home(): mh = "open" if _is_market_hours() else "closed" aut = "on" if _auto_enabled else "off" return f"Bot is running | market={mh} | auto={aut}", 200 @app.route("/status") def http_status(): now_s = datetime.now(SAUDI_TZ).strftime("%H:%M:%S KSA") now_et_s = _now_et().strftime("%H:%M:%S ET") mh = _is_market_hours() nx = int(_secs_to_next_close()) return { "status": "running", "time_ksa": now_s, "time_et": now_et_s, "market_open": mh, "auto_scan": _auto_enabled, "next_candle_secs": nx, "pump_stocks": len(pump_stocks), "sp500_stocks": len(sp500_stocks), }, 200 # ───────────────────────────────────────────────────────────────────────────── # Startup # ───────────────────────────────────────────────────────────────────────────── def _set_commands(): """Register bot commands so they appear when the user types / in Telegram.""" commands = [ {"command": "start", "description": "عرض المساعدة وقائمة الأوامر"}, {"command": "help", "description": "عرض المساعدة وقائمة الأوامر"}, {"command": "status", "description": "حالة البوت والسوق الآن"}, {"command": "today", "description": "أسهم pumped اليوم ≥15% (market hours)"}, {"command": "premarket", "description": "أسهم pumped اليوم ≥10% في الـ Pre-market"}, {"command": "pm", "description": "اختصار /premarket"}, {"command": "update", "description": "فحص يدوي — اختر الاستراتيجية"}, {"command": "auto", "description": "تفعيل أو إيقاف الفحص التلقائي"}, {"command": "test", "description": "فحص سهم مباشر — مثال: /test NVDA"}, ] result = _post("setMyCommands", json={"commands": commands}) if result.get("result"): print("✅ Bot commands registered successfully") else: print(f"⚠️ setMyCommands failed: {result}") def _startup(): """Send startup Telegram message; errors here must not kill the process.""" try: _set_commands() mh_stat = "✅ السوق مفتوح" if _is_market_hours() else "❌ السوق مغلق" nx_min = int(_secs_to_next_close() / 60) send( f"🤖 البوت جاهز! — {datetime.now(SAUDI_TZ).strftime('%H:%M KSA')}\n" f"{mh_stat} | الشمعة القادمة: {nx_min}د\n" f"⏰ الفحص التلقائي مفعّل (كل 15د market hours)\n" f"/update للفحص اليدوي | /status للحالة | /ping للـ health-check" ) except Exception as e: print(f"Startup message error (non-fatal): {e}") print("🤖 PUMP Bot starting (yfinance engine)…") threading.Thread(target=_startup, daemon=True).start() threading.Thread(target=poll, daemon=True).start() threading.Thread(target=candle_scheduler, daemon=True).start() if __name__ == "__main__": port = int(os.environ.get("PORT", 5000)) print(f"🌐 Flask listening on 0.0.0.0:{port} — /ping for health check") app.run(host="0.0.0.0", port=port, threaded=True)