az30667176-sudo commited on
Commit
bcbc063
·
1 Parent(s): 5e4439a

feat: use Stooq as primary data source (yfinance heavily rate-limited on HF IPs)

Browse files
Files changed (2) hide show
  1. fetcher.py +70 -58
  2. requirements.txt +4 -1
fetcher.py CHANGED
@@ -1,9 +1,9 @@
1
  """
2
- fetcher.py — Yahoo Finance OHLCV fetcher for Kronos Analyzer.
3
 
4
- On HF Spaces the IP is shared with many users and yfinance tends to hit
5
- rate limits. This version uses curl_cffi browser impersonation + per-ticker
6
- retries + random jitter delays.
7
  """
8
  from __future__ import annotations
9
 
@@ -14,31 +14,9 @@ from dataclasses import dataclass
14
  from datetime import datetime, timedelta
15
 
16
  import pandas as pd
17
- import yfinance as yf
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
- # Shared session across requests; curl_cffi impersonates a real browser
22
- _SESSION = None
23
-
24
-
25
- def _get_session():
26
- global _SESSION
27
- if _SESSION is not None:
28
- return _SESSION
29
- try:
30
- from curl_cffi import requests as curl_requests
31
- _SESSION = curl_requests.Session(impersonate="chrome")
32
- except Exception:
33
- import requests as py_requests
34
- _SESSION = py_requests.Session()
35
- _SESSION.headers.update({
36
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
37
- "AppleWebKit/537.36 (KHTML, like Gecko) "
38
- "Chrome/130.0.0.0 Safari/537.36"
39
- })
40
- return _SESSION
41
-
42
 
43
  @dataclass
44
  class TickerData:
@@ -47,57 +25,92 @@ class TickerData:
47
  timestamps: pd.Series
48
 
49
 
50
- def _download_one_once(ticker: str, start, end, interval: str) -> pd.DataFrame | None:
51
- """Single attempt, no retry."""
52
- session = _get_session()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  try:
54
- tk = yf.Ticker(ticker, session=session)
 
55
  df = tk.history(
56
  start=start.date().isoformat(),
57
  end=end.date().isoformat(),
58
  interval=interval,
59
  auto_adjust=True,
60
  )
61
- except Exception as exc:
62
- logger.warning(f"[{ticker}] history() error: {exc}")
63
- return None
64
-
65
- if df is None or df.empty:
66
- return None
67
-
68
- # Normalize columns
69
- df.columns = [str(c).lower() for c in df.columns]
70
- needed = ["open", "high", "low", "close", "volume"]
71
- for col in needed:
72
- if col not in df.columns:
73
- logger.warning(f"[{ticker}] missing column {col}; skipping")
74
  return None
75
- df = df[needed].dropna()
76
- if df.empty:
 
 
 
 
 
 
 
 
 
77
  return None
78
- return df
79
 
80
 
81
  def _download_one(ticker: str, lookback_days: int, interval: str = "1d") -> pd.DataFrame | None:
82
- """Download with retry + exponential backoff."""
83
  multiplier = 1.6 if interval == "1d" else 2.0
84
  period_days = int(lookback_days * multiplier) + 60
85
  end = datetime.utcnow()
86
  start = end - timedelta(days=period_days)
87
 
88
- max_retries = 4
89
- for attempt in range(max_retries):
90
- df = _download_one_once(ticker, start, end, interval)
 
 
 
 
 
 
 
91
  if df is not None and not df.empty:
 
92
  return df
 
 
93
 
94
- if attempt < max_retries - 1:
95
- # Exponential backoff with jitter
96
- delay = (2 ** attempt) + random.uniform(0.5, 1.5)
97
- logger.warning(f"[{ticker}] retry {attempt + 1}/{max_retries - 1} after {delay:.1f}s")
98
- time.sleep(delay)
 
 
 
 
99
 
100
- logger.warning(f"[{ticker}] failed after {max_retries} attempts")
101
  return None
102
 
103
 
@@ -109,9 +122,8 @@ def fetch_aligned(
109
  raw = {}
110
  failed: list[str] = []
111
  for i, t in enumerate(tickers):
112
- # Small jitter between requests to avoid bursty patterns
113
  if i > 0:
114
- time.sleep(random.uniform(0.3, 0.8))
115
  df = _download_one(t, lookback_days=lookback, interval=interval)
116
  if df is None or len(df) < lookback:
117
  failed.append(t)
 
1
  """
2
+ fetcher.py — OHLCV fetcher for Kronos Analyzer (HF Space version).
3
 
4
+ Primary source: Stooq (via pandas-datareader). Free, no API key, and doesn't
5
+ rate-limit cloud provider IPs the way Yahoo does.
6
+ Fallback: yfinance.
7
  """
8
  from __future__ import annotations
9
 
 
14
  from datetime import datetime, timedelta
15
 
16
  import pandas as pd
 
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  @dataclass
22
  class TickerData:
 
25
  timestamps: pd.Series
26
 
27
 
28
+ def _stooq_download(ticker: str, start, end) -> pd.DataFrame | None:
29
+ """Fetch from Stooq via pandas-datareader."""
30
+ try:
31
+ from pandas_datareader import data as pdr
32
+ df = pdr.DataReader(ticker, "stooq", start, end)
33
+ if df is None or df.empty:
34
+ return None
35
+ # Stooq returns descending order (newest first); sort ascending
36
+ df = df.sort_index(ascending=True)
37
+ # Normalize column names
38
+ df.columns = [str(c).lower() for c in df.columns]
39
+ needed = ["open", "high", "low", "close", "volume"]
40
+ for col in needed:
41
+ if col not in df.columns:
42
+ logger.warning(f"[{ticker}] stooq missing column {col}")
43
+ return None
44
+ df = df[needed].dropna()
45
+ if df.empty:
46
+ return None
47
+ return df
48
+ except Exception as exc:
49
+ logger.warning(f"[{ticker}] stooq error: {exc}")
50
+ return None
51
+
52
+
53
+ def _yfinance_download(ticker: str, start, end, interval: str = "1d") -> pd.DataFrame | None:
54
+ """Fallback: yfinance direct."""
55
  try:
56
+ import yfinance as yf
57
+ tk = yf.Ticker(ticker)
58
  df = tk.history(
59
  start=start.date().isoformat(),
60
  end=end.date().isoformat(),
61
  interval=interval,
62
  auto_adjust=True,
63
  )
64
+ if df is None or df.empty:
 
 
 
 
 
 
 
 
 
 
 
 
65
  return None
66
+ df.columns = [str(c).lower() for c in df.columns]
67
+ needed = ["open", "high", "low", "close", "volume"]
68
+ for col in needed:
69
+ if col not in df.columns:
70
+ return None
71
+ df = df[needed].dropna()
72
+ if df.empty:
73
+ return None
74
+ return df
75
+ except Exception as exc:
76
+ logger.warning(f"[{ticker}] yfinance error: {exc}")
77
  return None
 
78
 
79
 
80
  def _download_one(ticker: str, lookback_days: int, interval: str = "1d") -> pd.DataFrame | None:
81
+ """Try Stooq first (cloud-friendly), fall back to yfinance."""
82
  multiplier = 1.6 if interval == "1d" else 2.0
83
  period_days = int(lookback_days * multiplier) + 60
84
  end = datetime.utcnow()
85
  start = end - timedelta(days=period_days)
86
 
87
+ # Stooq needs ticker uppercase. For US stocks on Stooq, append `.US` if missing.
88
+ stooq_ticker = ticker.upper()
89
+ if "." not in stooq_ticker:
90
+ stooq_ticker_us = f"{stooq_ticker}.US"
91
+ else:
92
+ stooq_ticker_us = stooq_ticker
93
+
94
+ # Try Stooq with retry
95
+ for attempt in range(3):
96
+ df = _stooq_download(stooq_ticker_us, start, end)
97
  if df is not None and not df.empty:
98
+ logger.info(f"[{ticker}] ✓ stooq ({len(df)} bars)")
99
  return df
100
+ if attempt < 2:
101
+ time.sleep(random.uniform(0.5, 1.5))
102
 
103
+ # Fallback to yfinance
104
+ logger.info(f"[{ticker}] stooq failed, trying yfinance")
105
+ for attempt in range(3):
106
+ df = _yfinance_download(ticker, start, end, interval)
107
+ if df is not None and not df.empty:
108
+ logger.info(f"[{ticker}] ✓ yfinance ({len(df)} bars)")
109
+ return df
110
+ if attempt < 2:
111
+ time.sleep(2 ** attempt + random.uniform(0.5, 1.5))
112
 
113
+ logger.warning(f"[{ticker}] both sources failed")
114
  return None
115
 
116
 
 
122
  raw = {}
123
  failed: list[str] = []
124
  for i, t in enumerate(tickers):
 
125
  if i > 0:
126
+ time.sleep(random.uniform(0.2, 0.5))
127
  df = _download_one(t, lookback_days=lookback, interval=interval)
128
  if df is None or len(df) < lookback:
129
  failed.append(t)
requirements.txt CHANGED
@@ -6,5 +6,8 @@ safetensors==0.6.2
6
  tqdm==4.67.1
7
  pandas==2.2.2
8
  numpy<2.0
9
- # Pin yfinance to version before it added the websocket-based live module
 
 
10
  yfinance==0.2.52
 
 
6
  tqdm==4.67.1
7
  pandas==2.2.2
8
  numpy<2.0
9
+ # Stooq as primary source (no API key, no cloud IP rate limit)
10
+ pandas-datareader>=0.10.0
11
+ # yfinance as fallback (often rate-limited on cloud IPs)
12
  yfinance==0.2.52
13
+ websockets>=12.0