Spaces:
Running
Running
| import os | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime, timedelta | |
| import pandas as pd | |
| import pytz | |
| import requests | |
| import yfinance as yf | |
| CACHE_DIR = "scripts/cache" | |
| def ensure_cache_dir(): | |
| os.makedirs(CACHE_DIR, exist_ok=True) | |
| def get_cache_path(symbol): | |
| return os.path.join(CACHE_DIR, f"{symbol}.csv") | |
| def is_cache_fresh(symbol, max_age_hours=24): | |
| """Check if cache file exists and is younger than max_age_hours""" | |
| cache_path = get_cache_path(symbol) | |
| if not os.path.exists(cache_path): | |
| return False | |
| mtime = datetime.fromtimestamp(os.path.getmtime(cache_path)) | |
| return datetime.now() - mtime < timedelta(hours=max_age_hours) | |
| def load_from_cache(symbol): | |
| """Load data from cache CSV""" | |
| cache_path = get_cache_path(symbol) | |
| df = pd.read_csv(cache_path, parse_dates=["Date"], index_col=0) | |
| return df | |
| def save_to_cache(symbol, df): | |
| """Save DataFrame to cache CSV""" | |
| cache_path = get_cache_path(symbol) | |
| df.to_csv(cache_path, index=True) | |
| def download_symbol(symbol): | |
| """Download 60d of data for a symbol, using cache if fresh""" | |
| if is_cache_fresh(symbol): | |
| try: | |
| df = load_from_cache(symbol) | |
| return symbol, df | |
| except Exception: | |
| pass | |
| df = yf.download(symbol, period="60d", interval="1d", progress=False) | |
| if df is not None and not (hasattr(df, "empty") and df.empty): | |
| if isinstance(df.columns, pd.MultiIndex): | |
| df.columns = df.columns.get_level_values(0) | |
| save_to_cache(symbol, df) | |
| return symbol, df | |
| return symbol, None | |
| def fetch_ranking(rank_type, min_change=0.20): | |
| """Fetch a specific ranking type (5d, afterMarket, preMarket)""" | |
| items = [] | |
| page = 1 | |
| while True: | |
| r = requests.get( | |
| "https://quotes-gw.webullfintech.com/api/wlas/ranking/v9/rise", | |
| params={"regionId": 6, "rankType": rank_type, "pageIndex": page, "pageSize": 50}, | |
| headers={"appid": "wb_web_us", "origin": "https://www.webull.com"}, | |
| timeout=10, | |
| ) | |
| data = r.json() | |
| batch = data.get("data", []) | |
| if not batch: | |
| break | |
| for item in batch: | |
| values = item.get("values", {}) | |
| change_ratio = float(values.get("changeRatio", 0)) | |
| pch_ratio = float(values.get("pchRatio", 0)) | |
| # For 5d type, apply filter and stop condition | |
| if rank_type == "5d": | |
| if change_ratio < min_change: | |
| print(f" Page {page}: first item < {min_change * 100}% found, stopping pagination") | |
| return items | |
| # For afterMarket/preMarket: DON'T filter by pch_ratio | |
| # The API returns items in some order, we take top 3 pages to avoid spam | |
| # WALD with +64% should appear here! | |
| elif rank_type in ("afterMarket", "preMarket") and page > 3: # Max 3 pages (150 items) | |
| return items | |
| close = float(values.get("close", 0)) | |
| pre_close = float(values.get("preClose", 0)) | |
| today_regular = ((close / pre_close) - 1) if pre_close > 0 else 0 | |
| ticker = item.get("ticker", {}) | |
| items.append( | |
| { | |
| "symbol": ticker.get("symbol", ""), | |
| "name": ticker.get("name", ""), | |
| "change_5d": change_ratio, | |
| "today_regular": today_regular, | |
| "today_ah": pch_ratio, | |
| "source": rank_type, | |
| } | |
| ) | |
| if not data.get("hasMore"): | |
| break | |
| page += 1 | |
| return items | |
| def fetch_gainers(): | |
| """Obtiene gainers de múltiples rankings según sesión""" | |
| gainers = [] | |
| # SIEMPRE: 5d gainers | |
| print(" Fetching 5d ranking...") | |
| gainers += fetch_ranking("5d", min_change=0.20) | |
| # SEGÚN SESIÓN: agregar afterMarket o preMarket | |
| ny_tz = pytz.timezone("America/New_York") | |
| now_ny = datetime.now(ny_tz) | |
| hour = now_ny.hour + now_ny.minute / 60 | |
| # Extended hours: before regular (pre-market 4:00-9:30) or after (after-hours 16:00-23:59) | |
| if hour < 9.5 or hour >= 16: # NOT in regular session | |
| if 4 <= hour < 9.5: | |
| print(" [Pre-market: adding preMarket ranking]") | |
| gainers += fetch_ranking("preMarket", min_change=0.20) | |
| else: # After-hours or closed (16:00-23:59) | |
| print(" [After-hours/Closed: adding afterMarket ranking]") | |
| gainers += fetch_ranking("afterMarket", min_change=0.20) | |
| # ELIMINAR DUPLICADOS | |
| seen = set() | |
| unique_gainers = [] | |
| for g in gainers: | |
| if g["symbol"] not in seen: | |
| seen.add(g["symbol"]) | |
| unique_gainers.append(g) | |
| print(f" Total unique symbols: {len(unique_gainers)}") | |
| return unique_gainers | |
| def detect_spikes_parallel(gainers, limit=50, max_workers=5): | |
| """Detecta spikes usando descarga paralela con ThreadPoolExecutor y cache""" | |
| results = [] | |
| symbols_to_process = [g["symbol"] for g in gainers[:limit]] | |
| gainers_dict = {g["symbol"]: g for g in gainers[:limit]} | |
| ensure_cache_dir() | |
| with ThreadPoolExecutor(max_workers=max_workers) as executor: | |
| future_to_symbol = {executor.submit(download_symbol, sym): sym for sym in symbols_to_process} | |
| for future in as_completed(future_to_symbol): | |
| symbol = future_to_symbol[future] | |
| try: | |
| sym, df = future.result() | |
| if df is None or (hasattr(df, "empty") and df.empty) or len(df) < 5: | |
| continue | |
| if "Date" not in df.columns and df.index.name == "Date": | |
| df = df.reset_index() | |
| df = df.tail(60) | |
| avg_vol = float(df["Volume"].mean()) | |
| last_5 = df.tail(5).reset_index(drop=True) | |
| for i in range(len(last_5)): | |
| row = last_5.iloc[i] | |
| prev_idx = df.tail(5).index[i] - 1 | |
| prev_close = float(df.iloc[prev_idx]["Close"]) if prev_idx >= 0 else float(row["Close"]) | |
| spike = (float(row["Close"]) / prev_close) - 1 | |
| vol = float(row["Volume"]) | |
| if vol > 10_000_000 and vol > avg_vol and spike >= 0.20: | |
| gainer = gainers_dict.get(symbol, {}) | |
| results.append( | |
| { | |
| "symbol": symbol, | |
| "date": str(row["Date"].date()) | |
| if "Date" in row | |
| else str(df.iloc[prev_idx]["Date"].date()), | |
| "today_regular": gainer.get("today_regular", 0), | |
| "today_ah": gainer.get("today_ah", 0), | |
| "gain_5d": gainer.get("change_5d", 0), | |
| } | |
| ) | |
| break | |
| except Exception as e: | |
| print(f"Error processing {symbol}: {e}") | |
| results.sort(key=lambda x: x["gain_5d"], reverse=True) | |
| return results | |
| def get_session_sort_key(spikes): | |
| """Determine sort column based on market session (NYSE timezone)""" | |
| ny_tz = pytz.timezone("America/New_York") | |
| now_ny = datetime.now(ny_tz) | |
| hour = now_ny.hour + now_ny.minute / 60 | |
| if 9.5 <= hour < 16: | |
| print(" [Regular session - sorting by Today%]") | |
| return lambda x: x.get("today_regular", 0), "Today%" | |
| else: | |
| print(" [Extended session - sorting by Ext%]") | |
| return lambda x: x.get("today_ah", 0), "Ext%" | |
| if __name__ == "__main__": | |
| print("Fetching gainers (filtered >= 20% 5d)...") | |
| gainers = fetch_gainers() | |
| print(f"Got {len(gainers)} filtered gainers. Detecting spikes in parallel...") | |
| spikes = detect_spikes_parallel(gainers, limit=50, max_workers=5) | |
| # Separate extended movers (from afterMarket/preMarket) that didn't make it to spikes | |
| extended_movers = [] | |
| for g in gainers: | |
| if ( | |
| g.get("source") in ("afterMarket", "preMarket") | |
| and g.get("today_ah", 0) >= 0.20 | |
| and not any(s["symbol"] == g["symbol"] for s in spikes) | |
| ): | |
| extended_movers.append(g) | |
| # Historical Spikes: ALWAYS sort by Today% (regular session) | |
| spikes.sort(key=lambda x: x.get("today_regular", 0), reverse=True) | |
| print("\n=== Historical Spikes (Last 5 Days) ===") | |
| print(f" {'Sym':4} | {'Sk':3} | {'Day%':>5} | {'Ext%':>5}") | |
| today = datetime.now().date() | |
| for s in spikes: | |
| spike_date = datetime.strptime(s["date"], "%Y-%m-%d").date() | |
| days_ago = (today - spike_date).days | |
| day_label = f"D{days_ago}" if days_ago > 0 else "Hoy" | |
| day_pct = s.get("today_regular", 0) * 100 | |
| ext_pct = s.get("today_ah", 0) * 100 | |
| print(f" {s['symbol']:4} | {day_label:3} | {day_pct:>5.1f}% | {ext_pct:>5.1f}%") | |
| if extended_movers: | |
| print("\n=== Extended Hours Movers ===") | |
| print(f" {'Sym':4} | {'Day%':>5} | {'Ext%':>5}") | |
| # Sort by Ext% descending | |
| extended_movers.sort(key=lambda x: x.get("today_ah", 0), reverse=True) | |
| for g in extended_movers: | |
| day_pct = g.get("today_regular", 0) * 100 | |
| ext_pct = g.get("today_ah", 0) * 100 | |
| print(f" {g['symbol']:4} | {day_pct:>5.1f}% | {ext_pct:>5.1f}%") | |