""" pipeline/resolver.py -- Resolve a company name or ticker to structured metadata. Source priority: 1. yahooquery (free, no key, excellent Indian stock support) 2. FMP API (if key available) 3. Mark as unlisted with clear message """ from __future__ import annotations import sys from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).parent.parent)) from config import FMP_API_KEY, FMP_BASE, REQUEST_TIMEOUT import requests # -- CompanyMeta --------------------------------------------------------------- class CompanyMeta: def __init__(self, name, ticker, exchange, currency, sector, industry, country, description, website, mkt_cap, share_price, is_listed, source_note="", fiscal_year_end="March"): self.name = name self.ticker = ticker self.exchange = exchange self.currency = currency self.sector = sector self.industry = industry self.country = country self.description = description self.website = website self.mkt_cap = mkt_cap self.share_price = share_price self.is_listed = is_listed self.source_note = source_note self.fiscal_year_end = fiscal_year_end def to_dict(self): return self.__dict__.copy() # -- Indian company name -> NSE ticker map ------------------------------------- INDIAN_TICKERS = { # IT "tcs": "TCS.NS", "tata consultancy": "TCS.NS", "tata consultancy services": "TCS.NS", "infosys": "INFY.NS", "infy": "INFY.NS", "wipro": "WIPRO.NS", "hcl": "HCLTECH.NS", "hcl tech": "HCLTECH.NS", "hcl technologies": "HCLTECH.NS", "tech mahindra": "TECHM.NS", "techm": "TECHM.NS", "ltimindtree": "LTIM.NS", "lti mindtree": "LTIM.NS", "mphasis": "MPHASIS.NS", "persistent": "PERSISTENT.NS", "coforge": "COFORGE.NS", # Banking "hdfc bank": "HDFCBANK.NS", "hdfcbank": "HDFCBANK.NS", "icici bank": "ICICIBANK.NS", "icicibank": "ICICIBANK.NS", "axis bank": "AXISBANK.NS", "axisbank": "AXISBANK.NS", "sbi": "SBIN.NS", "state bank": "SBIN.NS", "state bank of india": "SBIN.NS", "kotak": "KOTAKBANK.NS", "kotak bank": "KOTAKBANK.NS", "kotak mahindra bank": "KOTAKBANK.NS", "yes bank": "YESBANK.NS", "indusind bank": "INDUSINDBK.NS", "bank of baroda": "BANKBARODA.NS", "pnb": "PNB.NS", "punjab national bank": "PNB.NS", "canara bank": "CANARABANK.NS", # Conglomerates / Energy "reliance": "RELIANCE.NS", "reliance industries": "RELIANCE.NS", "ril": "RELIANCE.NS", "tata": "TATAMOTORS.NS", "adani enterprises": "ADANIENT.NS", "adani ports": "ADANIPORTS.NS", "adani green": "ADANIGREEN.NS", "adani total gas": "ATGL.NS", "ongc": "ONGC.NS", "oil and natural gas": "ONGC.NS", "ioc": "IOC.NS", "indian oil": "IOC.NS", "bpcl": "BPCL.NS", "bharat petroleum": "BPCL.NS", "coal india": "COALINDIA.NS", # FMCG "hindustan unilever": "HINDUNILVR.NS", "hul": "HINDUNILVR.NS", "itc": "ITC.NS", "nestle india": "NESTLEIND.NS", "nestle": "NESTLEIND.NS", "britannia": "BRITANNIA.NS", "dabur": "DABUR.NS", "marico": "MARICO.NS", "godrej consumer": "GODREJCP.NS", "godrej": "GODREJCP.NS", "emami": "EMAMILTD.NS", # Pharma "sun pharma": "SUNPHARMA.NS", "sun pharmaceutical": "SUNPHARMA.NS", "cipla": "CIPLA.NS", "dr reddy": "DRREDDY.NS", "dr reddys": "DRREDDY.NS", "divi's labs": "DIVISLAB.NS", "divis": "DIVISLAB.NS", "aurobindo": "AUROPHARMA.NS", "lupin": "LUPIN.NS", "torrent pharma": "TORNTPHARM.NS", # Auto "maruti": "MARUTI.NS", "maruti suzuki": "MARUTI.NS", "tata motors": "TATAMOTORS.NS", "m&m": "M&M.NS", "mahindra": "M&M.NS", "mahindra and mahindra": "M&M.NS", "hero motocorp": "HEROMOTOCO.NS", "hero": "HEROMOTOCO.NS", "bajaj auto": "BAJAJ-AUTO.NS", "eicher": "EICHERMOT.NS", "royal enfield": "EICHERMOT.NS", "tvs motor": "TVSMOTOR.NS", # Finance (non-bank) "bajaj finance": "BAJFINANCE.NS", "bajaj finserv": "BAJAJFINSV.NS", "hdfc life": "HDFCLIFE.NS", "sbi life": "SBILIFE.NS", "lic": "LICI.NS", "icici prudential": "ICICIPRULI.NS", "muthoot": "MUTHOOTFIN.NS", # Infra / Utilities "larsen": "LT.NS", "l&t": "LT.NS", "larsen and toubro": "LT.NS", "ntpc": "NTPC.NS", "power grid": "POWERGRID.NS", "tata power": "TATAPOWER.NS", "adani power": "ADANIPOWER.NS", # Cement / Materials "ultratech": "ULTRACEMCO.NS", "ultratech cement": "ULTRACEMCO.NS", "shree cement": "SHREECEM.NS", "ambuja": "AMBUJACEM.NS", "acc": "ACC.NS", "grasim": "GRASIM.NS", "tata steel": "TATASTEEL.NS", "jsw steel": "JSWSTEEL.NS", "hindalco": "HINDALCO.NS", "vedanta": "VEDL.NS", "jspl": "JINDALSTEL.NS", # Paints / Consumer "asian paints": "ASIANPAINT.NS", "berger paints": "BERGEPAINT.NS", "pidilite": "PIDILITIND.NS", "havells": "HAVELLS.NS", "voltas": "VOLTAS.NS", "titan": "TITAN.NS", "tanishq": "TITAN.NS", # Retail / Consumer "dmart": "DMART.NS", "avenue supermarts": "DMART.NS", "trent": "TRENT.NS", "zomato": "ZOMATO.NS", "nykaa": "NYKAA.NS", "policybazaar": "POLICYBZR.NS", # Telecom "airtel": "BHARTIARTL.NS", "bharti airtel": "BHARTIARTL.NS", "jio": "RELIANCE.NS", # Jio is part of Reliance # IT Infrastructure "hcl infosystems": "HCLISEC.NS", "dixon": "DIXON.NS", } def _guess_ticker(query: str) -> Optional[str]: """Try to find an Indian NSE ticker for a given name/query.""" q = query.lower().strip() # Clean up standard suffixes for safer matching clean_q = q.replace(".ns", "").replace(".bo", "").replace(" ltd", "").replace(" limited", "").strip() # Exact match if clean_q in INDIAN_TICKERS: return INDIAN_TICKERS[clean_q] if q in INDIAN_TICKERS: return INDIAN_TICKERS[q] return None # -- yahooquery resolution ------------------------------------------------------- def _yq_resolve(ticker: str, log_fn=None) -> Optional[CompanyMeta]: """Try to resolve a ticker via yahooquery. Returns CompanyMeta or None.""" def log(msg): if log_fn: log_fn(msg) try: import yahooquery as yq t = yq.Ticker(ticker) # Yahooquery returns dicts with ticker as the key price_data = t.price.get(ticker, {}) profile_data = t.summary_profile.get(ticker, {}) if isinstance(price_data, str) or isinstance(profile_data, str): # Yahooquery sometimes returns strings like "No fundamentals data found" raise ValueError(f"yahooquery returned string instead of dict: {price_data}") name = price_data.get("longName") or price_data.get("shortName", "") if not name: raise ValueError(f"yahooquery returned empty name for {ticker}") exchange_code = price_data.get("exchange", "") currency = price_data.get("currency", "INR") exchange_map = { "NSI": "NSE (India)", "BSE": "BSE (India)", "NMS": "NASDAQ (USA)", "NYQ": "NYSE (USA)", "LSE": "LSE (UK)", } exchange_display = exchange_map.get(exchange_code, exchange_code or "Unknown") country = profile_data.get("country", "India") # Fiscal year end fy_end = "March" if currency == "INR" else "December" log(f" [OK] yahooquery: {name} | {ticker} | {exchange_display}") return CompanyMeta( name=name, ticker=ticker, exchange=exchange_display, currency=currency, sector=profile_data.get("sector", ""), industry=profile_data.get("industry", ""), country=country, description=profile_data.get("longBusinessSummary", ""), website=profile_data.get("website", ""), mkt_cap=price_data.get("marketCap"), share_price=price_data.get("regularMarketPrice"), is_listed=True, source_note="Data sourced from Yahoo Finance (yahooquery).", fiscal_year_end=fy_end, ) except Exception as e: log(f" [ERR] yahooquery failed for {ticker}: {e}") # FALLBACK: If yahooquery fails, try screener.in for Indian tickers if ticker.endswith(".NS") or ticker.endswith(".BO"): log(f" -> Trying screener.in fallback for {ticker}...") try: base = ticker.split(".")[0].upper() import requests from bs4 import BeautifulSoup r = requests.get(f"https://www.screener.in/company/{base}/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10) if r.ok: soup = BeautifulSoup(r.text, "lxml") h1 = soup.find("h1") if h1: name = h1.get_text(strip=True) desc_div = soup.find("div", class_="company-profile") desc = desc_div.get_text(strip=True) if desc_div else "" log(f" [OK] screener.in resolved: {name}") return CompanyMeta( name=name, ticker=ticker, exchange="NSE/BSE (India)", currency="INR", sector="Unknown", industry="Unknown", country="India", description=desc[:500], website="", mkt_cap=None, share_price=None, is_listed=True, source_note="Data sourced from screener.in (yahooquery fallback).", fiscal_year_end="March" ) except Exception as e2: log(f" [ERR] screener.in fallback failed: {e2}") return None # -- FMP resolution ------------------------------------------------------------ def _fmp_get(path: str, params: dict | None = None): if not FMP_API_KEY: return None url = f"{FMP_BASE}{path}" p = {"apikey": FMP_API_KEY} if params: p.update(params) try: r = requests.get(url, params=p, timeout=REQUEST_TIMEOUT) r.raise_for_status() return r.json() except Exception: return None def _fmp_resolve(query: str, log_fn=None) -> Optional[CompanyMeta]: """Try FMP search for global companies.""" def log(msg): if log_fn: log_fn(msg) if not FMP_API_KEY: return None log(" -> Searching FMP ...") # Direct profile profile = _fmp_get(f"/v3/profile/{query.upper()}") if not (profile and isinstance(profile, list) and profile[0].get("symbol")): # Search results = _fmp_get("/v3/search", {"query": query, "limit": 5}) if results: best = results[0] profile = _fmp_get(f"/v3/profile/{best['symbol']}") if not (profile and isinstance(profile, list)): return None p = profile[0] if not p.get("companyName"): return None exchange = p.get("exchange", "") or p.get("exchangeShortName", "") exchange_map = {"NSE":"NSE (India)","BSE":"BSE (India)","NASDAQ":"NASDAQ (USA)","NYSE":"NYSE (USA)"} log(f" [OK] FMP: {p.get('companyName')} ({p.get('symbol')})") return CompanyMeta( name=p.get("companyName",""), ticker=p.get("symbol",""), exchange=exchange_map.get(exchange, exchange), currency=p.get("currency","USD"), sector=p.get("sector",""), industry=p.get("industry",""), country=p.get("country",""), description=p.get("description",""), website=p.get("website",""), mkt_cap=p.get("mktCap"), share_price=p.get("price"), is_listed=True, source_note="Data sourced from FMP.", fiscal_year_end="December", ) # -- yahooquery search by company name ------------------------------------------ def _yq_search(query: str, log_fn=None) -> Optional[CompanyMeta]: """Use yahooquery's search to find a ticker from a company name.""" def log(msg): if log_fn: log_fn(msg) try: import yahooquery as yq log(f" -> yahooquery search for '{query}' ...") results = yq.search(query) quotes = results.get("quotes", []) if not quotes: return None # Take the first relevant result for q in quotes: symbol = q.get("symbol", "") qtype = q.get("quoteType", "") if qtype in ("EQUITY", "MUTUALFUND") and symbol: log(f" -> Found: {q.get('longname', symbol)} ({symbol})") return _yq_resolve(symbol, log_fn) return None except Exception as e: log(f" [ERR] yahooquery search failed: {e}") return None # -- Public resolve() ---------------------------------------------------------- def resolve(query: str, log_fn=None) -> CompanyMeta: """ Resolve a company name or ticker to CompanyMeta. Source priority: Indian map -> yahooquery direct -> yahooquery search -> FMP -> unlisted """ def log(msg): if log_fn: log_fn(msg) query = query.strip() log(f"Resolving: '{query}' ...") # 1. Check Indian name map -> resolve via yahooquery indian_ticker = _guess_ticker(query) if indian_ticker: log(f" [OK] Matches known Indian map -> {indian_ticker}") meta = _yq_resolve(indian_ticker, log_fn) if meta: return meta # 2. Try direct yahooquery (query as-is, and with .NS suffix) for candidate in [query.upper(), f"{query.upper()}.NS", f"{query.upper()}.BO"]: log(f" -> Trying direct ticker: {candidate}") meta = _yq_resolve(candidate, log_fn) if meta: return meta # 3. yahooquery search meta = _yq_search(query, log_fn) if meta: return meta # 4. FMP Search (for global companies) meta = _fmp_resolve(query, log_fn) if meta: return meta # 5. Unlisted fallback log(f" ⚠ '{query}' not found in any public market database.") log(" -> Treating as unlisted / private company.") return CompanyMeta( name=query, ticker="UNLISTED", exchange="Unlisted / Private", currency="INR", sector="Unknown", industry="Unknown", country="India", description="", website="", mkt_cap=None, share_price=None, is_listed=False, source_note=( f"'{query}' could not be found. It may be unlisted, private, or use a different name. " "Try using the exact BSE/NSE ticker (e.g. RELIANCE, TCS, HDFCBANK)." ), fiscal_year_end="March", )