import pandas as pd import logging import os logger = logging.getLogger(__name__) class TickerDatabase: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super(TickerDatabase, cls).__new__(cls) cls._instance.df = None return cls._instance def load_data(self, filepath: str): """Loads and cleans the CSV data into memory.""" if not os.path.exists(filepath): logger.error(f"Stock data file not found at: {filepath}") return try: # Read CSV with flexible encoding try: self.df = pd.read_csv(filepath, encoding='utf-8') except UnicodeDecodeError: self.df = pd.read_csv(filepath, encoding='latin1') # Clean column names self.df.columns = self.df.columns.str.strip() # Ensure required columns if 'Name' not in self.df.columns: logger.error("CSV missing required 'Name' column.") self.df = None return # Clean Name column self.df['Name'] = self.df['Name'].astype(str).str.strip() # Numeric cleaning for all metric columns # We explicitly clean these columns to be usable floats numeric_cols = [ 'LTP', 'Change(%)', 'Open', 'Volume', 'Market Cap (Cr.)', 'PE Ratio', 'Industry PE', '52W High', '52W Low', '1M Returns', '3M Returns', '1 Yr Returns', '3 Yr Returns', '5 Yr Returns', 'PB Ratio', 'Dividend', 'ROE', 'ROCE', 'EPS', '50 DMA', '200 DMA', 'RSI', 'Margin Funding', 'Margin Pledge' ] for col in numeric_cols: if col in self.df.columns: # Remove commas, %, and whitespace, then coerce to numeric # Force string conversion first to handle mixed types safely self.df[col] = ( self.df[col] .astype(str) .str.replace(',', '', regex=False) .str.replace('%', '', regex=False) .str.strip() ) self.df[col] = pd.to_numeric(self.df[col], errors='coerce').fillna(0.0) logger.info(f"Loaded {len(self.df)} tickers from {filepath}") except Exception as e: logger.error(f"Failed to load stock data: {e}") self.df = None def search_names(self, query: str, limit: int = 10): """Searches for company names containing the query string (case-insensitive).""" if self.df is None or not query: return [] try: mask = self.df['Name'].str.contains(query, case=False, na=False) matches = self.df.loc[mask, 'Name'].head(limit).tolist() return matches except Exception as e: logger.error(f"Error filtering names: {e}") return [] def validate_name(self, name: str) -> bool: """Checks if a company name exists in the database (exact match, case-insensitive).""" if self.df is None or not name: return False mask = self.df['Name'].str.lower() == name.strip().lower() return mask.any() def get_company_details(self, name: str) -> dict: """Returns the full row of data for a given company name.""" if self.df is None or not name: return None try: # Case-insensitive exact match row = self.df[self.df['Name'].str.lower() == name.strip().lower()] if row.empty: return None # Convert to dictionary and handle Nan/Inf for JSON serialization data = row.iloc[0].to_dict() return data except Exception as e: logger.error(f"Error getting details for {name}: {e}") return None def get_peers_by_industry(self, industry_pe: float, exclude_name: str, limit: int = 5) -> list: """ Finds peers with the same or similar Industry PE. This uses the logic: Same Industry PE = Same Sector/Industry. """ if self.df is None or industry_pe == 0: return [] try: # Filter by matching Industry PE # Using a small tolerance just in case of float weirdness, though exact match is requested peers = self.df[ (self.df['Industry PE'] >= industry_pe - 0.1) & (self.df['Industry PE'] <= industry_pe + 0.1) & (self.df['Name'].str.lower() != exclude_name.lower()) ] # Use 'Market Cap (Cr.)' for sorting if available, else random slice if 'Market Cap (Cr.)' in self.df.columns: peers = peers.sort_values(by='Market Cap (Cr.)', ascending=False) return peers.head(limit).to_dict('records') except Exception as e: logger.error(f"Error finding peers: {e}") return [] # Global instance accessor def get_ticker_db(): return TickerDatabase()