"""Yahoo Finance day_gainers fetcher for TGTC strategy. Uses yfinance.screen("day_gainers") instead of the raw httpx endpoint (query1.finance.yahoo.com/v1/finance/screener/predefined/saved was IP-blocked after the zombie-daemon burst; yfinance routes through a different path). Usage: # Async (in engine) quotes = await fetch_day_gainers() # Sync one-shot (CLI / testing) quotes = fetch_day_gainers_sync() CLI: python -m apps.tgtc_trader.yahoo_gainers --once """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass from typing import Any import yfinance as yf log = logging.getLogger(__name__) # Deprecated / blocked endpoint constants kept as comments for reference: # _YAHOO_URL = "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?..." # _HEADERS = {"User-Agent": "Mozilla/5.0 ..."} _TIMEOUT_SECS = 12 # kept for documentation; yfinance manages its own timeout _MAX_RETRIES = 3 _RETRY_DELAY_SECS = 3.0 @dataclass class GainerQuote: symbol: str rank: int price: float pct_change: float # e.g. 0.12 = +12% volume: float market_cap: float | None exchange: str | None def _parse_yf_quotes(result: dict[str, Any]) -> list[GainerQuote]: """Extract GainerQuote list from yfinance.screen() result dict.""" try: quotes_raw = result.get("quotes", []) except (AttributeError, TypeError): log.warning("TGTC Yahoo: unexpected yfinance screen result structure") return [] out: list[GainerQuote] = [] for rank, q in enumerate(quotes_raw, start=1): try: sym = q.get("symbol", "").upper() if not sym: continue price = float(q.get("regularMarketPrice") or 0) # yfinance already returns pct as raw value (e.g. 31.16834 = +31.17%) pct = float(q.get("regularMarketChangePercent") or 0) / 100.0 vol = float(q.get("regularMarketVolume") or 0) mktcap_raw = q.get("marketCap") mktcap = float(mktcap_raw) if mktcap_raw is not None else None exchange = q.get("exchange") or q.get("fullExchangeName") out.append(GainerQuote( symbol=sym, rank=rank, price=price, pct_change=pct, volume=vol, market_cap=mktcap, exchange=exchange, )) except Exception as exc: log.debug("TGTC Yahoo: skip quote parse error: %s", exc) return out async def fetch_day_gainers(count: int = 100) -> list[GainerQuote]: """Async fetch of Yahoo day_gainers screener via yfinance. Retries up to _MAX_RETRIES times.""" last_exc: Exception | None = None for attempt in range(_MAX_RETRIES): try: result = await asyncio.to_thread(yf.screen, "day_gainers", count=count) return _parse_yf_quotes(result) except Exception as exc: last_exc = exc log.warning("TGTC Yahoo: fetch attempt %d/%d failed: %s", attempt + 1, _MAX_RETRIES, exc) if attempt < _MAX_RETRIES - 1: await asyncio.sleep(_RETRY_DELAY_SECS * (attempt + 1)) log.error("TGTC Yahoo: all %d attempts failed: %s", _MAX_RETRIES, last_exc) return [] def fetch_day_gainers_sync(count: int = 100) -> list[GainerQuote]: """Synchronous wrapper for testing or CLI usage.""" return asyncio.run(fetch_day_gainers(count)) # ── CLI one-shot ────────────────────────────────────────────────────────────── if __name__ == "__main__": import argparse logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") parser = argparse.ArgumentParser(description="TGTC Yahoo day_gainers one-shot fetch") parser.add_argument("--once", action="store_true", help="Fetch once and print") parser.add_argument("--count", type=int, default=100) args = parser.parse_args() if args.once: quotes = fetch_day_gainers_sync(args.count) print(f"Fetched {len(quotes)} gainers:") for q in quotes[:20]: print(f" {q.rank:3d}. {q.symbol:<8s} {q.pct_change*100:+.1f}% " f"${q.price:.2f} vol={q.volume:,.0f}")