Spaces:
Running
on
Zero
Running
on
Zero
import time | |
import streamlit as st | |
import yfinance as yf | |
from functools import wraps | |
import pandas as pd | |
import numpy as np | |
import random | |
from datetime import datetime, timedelta | |
try: | |
import pandas_datareader.data as web | |
PANDAS_DATAREADER_AVAILABLE = True | |
except ImportError: | |
PANDAS_DATAREADER_AVAILABLE = False | |
st.warning("pandas_datareader not available. Install it with: pip install pandas-datareader") | |
class RateLimitManager: | |
"""Manages rate limiting for API calls""" | |
def __init__(self, min_delay=3.0): | |
self.min_delay = min_delay | |
self.last_call_time = 0 | |
def wait_if_needed(self): | |
"""Ensure minimum delay between API calls""" | |
current_time = time.time() | |
time_since_last_call = current_time - self.last_call_time | |
if time_since_last_call < self.min_delay: | |
sleep_time = self.min_delay - time_since_last_call + random.uniform(0.5, 1.5) | |
time.sleep(sleep_time) | |
self.last_call_time = time.time() | |
# Global rate limit manager | |
rate_limiter = RateLimitManager() | |
def create_sample_data(ticker, period='1mo'): | |
"""Create sample data when API is unavailable""" | |
# Define sample data for common tickers | |
sample_data = { | |
'NVDA': {'base_price': 450, 'volatility': 0.03, 'trend': 0.001}, | |
'AAPL': {'base_price': 190, 'volatility': 0.02, 'trend': 0.0005}, | |
'GOOGL': {'base_price': 140, 'volatility': 0.025, 'trend': 0.0008}, | |
'MSFT': {'base_price': 420, 'volatility': 0.02, 'trend': 0.0007}, | |
'AMZN': {'base_price': 150, 'volatility': 0.025, 'trend': 0.0006} | |
} | |
# Get parameters for ticker or use defaults | |
params = sample_data.get(ticker, {'base_price': 100, 'volatility': 0.02, 'trend': 0.0005}) | |
# Generate date range based on period | |
if period == 'max' or period == '1y': | |
days = 252 | |
elif period == '6mo': | |
days = 126 | |
elif period == '1mo': | |
days = 30 | |
else: | |
days = 30 | |
# Create date range | |
end_date = datetime.now() | |
start_date = end_date - timedelta(days=days) | |
dates = pd.date_range(start=start_date, end=end_date, freq='D') | |
# Remove weekends | |
dates = dates[dates.weekday < 5] | |
# Generate price data | |
np.random.seed(42) # For consistent sample data | |
returns = np.random.normal(params['trend'], params['volatility'], len(dates)) | |
prices = [params['base_price']] | |
for ret in returns[1:]: | |
prices.append(prices[-1] * (1 + ret)) | |
# Create DataFrame | |
df = pd.DataFrame(index=dates[:len(prices)]) | |
df['Close'] = prices | |
df['Open'] = df['Close'].shift(1).fillna(df['Close']) | |
df['High'] = df['Close'] * (1 + np.random.uniform(0, 0.02, len(df))) | |
df['Low'] = df['Close'] * (1 - np.random.uniform(0, 0.02, len(df))) | |
df['Volume'] = np.random.randint(1000000, 10000000, len(df)) | |
return df | |
def retry_with_backoff(max_retries=5, base_delay=10): | |
"""Decorator for retrying functions with exponential backoff""" | |
def decorator(func): | |
def wrapper(*args, **kwargs): | |
for attempt in range(max_retries): | |
try: | |
rate_limiter.wait_if_needed() | |
return func(*args, **kwargs) | |
except Exception as e: | |
error_msg = str(e).lower() | |
if any(keyword in error_msg for keyword in ['rate', 'limit', '429', 'too many requests']): | |
if attempt < max_retries - 1: | |
wait_time = base_delay * (2 ** attempt) + random.uniform(2, 5) | |
st.warning(f"🚫 Rate limit hit. Waiting {wait_time:.1f} seconds before retry {attempt + 2}/{max_retries}...") | |
time.sleep(wait_time) | |
continue | |
else: | |
st.error("⏱️ Rate limit exceeded after all retries. Using sample data.") | |
return None | |
elif any(keyword in error_msg for keyword in ['expecting value', 'no timezone', 'delisted', 'json']): | |
if attempt < max_retries - 1: | |
wait_time = base_delay + random.uniform(2, 4) | |
st.warning(f"🔄 Data parsing error. Retrying in {wait_time:.1f} seconds... (attempt {attempt + 2}/{max_retries})") | |
time.sleep(wait_time) | |
continue | |
else: | |
st.warning("⚠️ Unable to fetch real data. Using sample data for demonstration.") | |
return None | |
else: | |
if attempt < max_retries - 1: | |
wait_time = base_delay + random.uniform(1, 3) | |
st.warning(f"❗ Error: {str(e)[:100]}... Retrying in {wait_time:.1f} seconds...") | |
time.sleep(wait_time) | |
continue | |
else: | |
st.error(f"❌ Failed after {max_retries} attempts: {str(e)[:100]}...") | |
return None | |
return None | |
return wrapper | |
return decorator | |
def fetch_data_with_stooq(ticker_symbol, start_date=None, end_date=None, period='1mo'): | |
"""Fetch stock data using pandas_datareader with stooq as source""" | |
if not PANDAS_DATAREADER_AVAILABLE: | |
return None | |
try: | |
# Convert period to date range if start/end not provided | |
if start_date is None or end_date is None: | |
end_date = datetime.now() | |
if period == 'max' or period == '1y': | |
start_date = end_date - timedelta(days=365) | |
elif period == '6mo': | |
start_date = end_date - timedelta(days=180) | |
elif period == '1mo': | |
start_date = end_date - timedelta(days=30) | |
elif period == '5d': | |
start_date = end_date - timedelta(days=5) | |
else: | |
start_date = end_date - timedelta(days=30) | |
# Fetch data from stooq | |
df = web.DataReader(ticker_symbol, 'stooq', start_date, end_date) | |
if df.empty: | |
return None | |
# Stooq returns data in reverse chronological order, so sort it | |
df = df.sort_index() | |
# Ensure we have the required columns | |
required_columns = ['Open', 'High', 'Low', 'Close', 'Volume'] | |
if all(col in df.columns for col in required_columns): | |
return df | |
else: | |
st.warning(f"Missing columns in stooq data: {[col for col in required_columns if col not in df.columns]}") | |
return None | |
except Exception as e: | |
st.error(f"Error fetching data from stooq: {str(e)}") | |
return None | |
def safe_yfinance_call(ticker_symbol, operation='history', **kwargs): | |
"""Safely call multiple data sources with fallback to sample data""" | |
# First try stooq (pandas_datareader) for historical data | |
if operation == 'history' and PANDAS_DATAREADER_AVAILABLE: | |
try: | |
st.sidebar.info(f"🔄 Trying stooq API for {ticker_symbol}...") | |
stooq_data = fetch_data_with_stooq( | |
ticker_symbol, | |
start_date=kwargs.get('start'), | |
end_date=kwargs.get('end'), | |
period=kwargs.get('period', '1mo') | |
) | |
if stooq_data is not None and not stooq_data.empty: | |
st.sidebar.success(f"✅ Real data from stooq for {ticker_symbol}") | |
return stooq_data | |
else: | |
st.sidebar.warning(f"⚠️ Stooq failed for {ticker_symbol}") | |
except Exception as e: | |
st.sidebar.warning(f"⚠️ Stooq error: {str(e)[:50]}...") | |
# If stooq fails or for info operation, try yfinance as backup | |
try: | |
st.sidebar.info(f"🔄 Trying yfinance API for {ticker_symbol}...") | |
ticker = yf.Ticker(ticker_symbol) | |
if operation == 'history': | |
result = ticker.history( | |
timeout=10, | |
prepost=False, | |
auto_adjust=True, | |
back_adjust=False, | |
repair=True, | |
keepna=False, | |
actions=False, | |
**kwargs | |
) | |
if result is not None and not result.empty and len(result) > 0: | |
st.sidebar.success(f"✅ Real data from yfinance for {ticker_symbol}") | |
return result | |
else: | |
st.sidebar.warning(f"⚠️ yfinance returned empty data for {ticker_symbol}") | |
elif operation == 'info': | |
result = ticker.info | |
if result and isinstance(result, dict) and len(result) > 1: | |
st.sidebar.success(f"✅ Info from yfinance for {ticker_symbol}") | |
return result | |
else: | |
st.sidebar.warning(f"⚠️ yfinance info empty for {ticker_symbol}") | |
else: | |
raise ValueError(f"Unsupported operation: {operation}") | |
except Exception as e: | |
st.sidebar.warning(f"⚠️ yfinance also failed: {str(e)[:50]}...") | |
# Finally fallback to sample data | |
if operation == 'history': | |
st.sidebar.warning(f"📊 Using sample data for {ticker_symbol}") | |
return create_sample_data(ticker_symbol, kwargs.get('period', '1mo')) | |
elif operation == 'info': | |
sample_prices = { | |
'NVDA': 450, 'AAPL': 190, 'GOOGL': 140, 'MSFT': 420, 'AMZN': 150 | |
} | |
base_price = sample_prices.get(ticker_symbol, 100) | |
return { | |
'symbol': ticker_symbol, | |
'shortName': f'{ticker_symbol} Inc.', | |
'currentPrice': base_price + random.uniform(-2, 2), | |
'previousClose': base_price | |
} | |
else: | |
raise Exception(f"All data sources failed for {ticker_symbol}") | |
def get_cached_data(cache_key, ttl_seconds=300): | |
"""Get cached data from session state if still valid""" | |
if cache_key in st.session_state: | |
cache_time_key = f"cache_time_{cache_key}" | |
if cache_time_key in st.session_state: | |
cache_time = st.session_state[cache_time_key] | |
if time.time() - cache_time < ttl_seconds: | |
return st.session_state[cache_key] | |
return None | |
def set_cached_data(cache_key, data): | |
"""Cache data in session state with timestamp""" | |
st.session_state[cache_key] = data | |
st.session_state[f"cache_time_{cache_key}"] = time.time() | |
def clear_cache(pattern=None): | |
"""Clear cached data matching pattern""" | |
if pattern is None: | |
# Clear all cache | |
keys_to_remove = [key for key in st.session_state.keys() | |
if key.startswith('cache_time_') or key.startswith('data_')] | |
else: | |
keys_to_remove = [key for key in st.session_state.keys() if pattern in key] | |
for key in keys_to_remove: | |
del st.session_state[key] | |
return len(keys_to_remove) | |
def format_error_message(error): | |
"""Format error messages for better user experience""" | |
error_str = str(error).lower() | |
if "rate" in error_str or "limit" in error_str: | |
return ("🚫 **Rate Limit Exceeded**\n\n" | |
"Yahoo Finance has temporarily limited your requests. This happens when too many requests are made in a short time.\n\n" | |
"**What you can do:**\n" | |
"- Wait 5-10 minutes before trying again\n" | |
"- Use the cached data if available\n" | |
"- Try a different stock ticker\n\n" | |
"The app will automatically retry with delays between requests.") | |
elif "network" in error_str or "connection" in error_str: | |
return ("🌐 **Network Error**\n\n" | |
"There seems to be a connectivity issue.\n\n" | |
"**What you can do:**\n" | |
"- Check your internet connection\n" | |
"- Try refreshing the page\n" | |
"- Wait a moment and try again") | |
else: | |
return f"❌ **Error**: {str(error)}" | |
def display_cache_info(): | |
"""Display cache information in sidebar""" | |
with st.sidebar: | |
with st.expander("Cache Information"): | |
cache_items = [key for key in st.session_state.keys() | |
if key.startswith('data_') or key.startswith('model_data_')] | |
if cache_items: | |
st.write(f"**Cached items:** {len(cache_items)}") | |
for item in cache_items[:5]: # Show first 5 items | |
cache_time_key = f"cache_time_{item}" | |
if cache_time_key in st.session_state: | |
cache_time = st.session_state[cache_time_key] | |
age_minutes = (time.time() - cache_time) / 60 | |
st.write(f"• {item.replace('data_', '')}: {age_minutes:.1f}m ago") | |
if len(cache_items) > 5: | |
st.write(f"... and {len(cache_items) - 5} more") | |
if st.button("Clear All Cache"): | |
cleared = clear_cache() | |
st.success(f"Cleared {cleared} cached items") | |
st.experimental_rerun() | |
else: | |
st.write("No cached data") | |