#!/usr/bin/env python3 """ Unified data source layer: LongPort (primary) + Yahoo Finance (fallback). LongPort provides: PE, PB, EPS, BPS, dividend_yield, market_cap, realtime quotes Yahoo Finance provides: operating margins, ROE, debt ratios, analyst data, earnings history """ import os import sys from dataclasses import dataclass, field from typing import Optional, Literal # Lazy imports to avoid startup cost _longport_ctx = None _yf = None def _get_longport_context(): """Get or create LongPort QuoteContext (singleton).""" global _longport_ctx if _longport_ctx is None: try: from longport import openapi # Map LONGBRIDGE_* to LONGPORT_* config = {} with open(os.path.expanduser("~/.bashrc"), "r") as f: for line in f: if line.startswith("export LONGBRIDGE_"): key, value = line.strip().split("=", 1) config[key.replace("export ", "")] = value os.environ["LONGPORT_APP_KEY"] = config.get("LONGBRIDGE_APP_KEY", "") os.environ["LONGPORT_APP_SECRET"] = config.get("LONGBRIDGE_APP_SECRET", "") os.environ["LONGPORT_ACCESS_TOKEN"] = config.get("LONGBRIDGE_ACCESS_TOKEN", "") cfg = openapi.Config.from_env() _longport_ctx = openapi.QuoteContext(config=cfg) except Exception as e: print(f"LongPort init failed: {e}", file=sys.stderr) return _longport_ctx def _get_yfinance(): """Lazy import yfinance.""" global _yf if _yf is None: try: import yfinance _yf = yfinance except ImportError: print("yfinance not installed, Yahoo fallback disabled", file=sys.stderr) return _yf @dataclass class UnifiedStockData: """Unified stock data from multiple sources.""" # Identity symbol: str name: str = "" market: str = "" # "US", "HK", "CN" currency: str = "" # Price data (LongPort primary) last_price: Optional[float] = None prev_close: Optional[float] = None open_price: Optional[float] = None high: Optional[float] = None low: Optional[float] = None volume: Optional[int] = None turnover: Optional[float] = None # Valuation metrics (LongPort primary) pe_ttm: Optional[float] = None pb: Optional[float] = None dividend_yield: Optional[float] = None eps_ttm: Optional[float] = None bps: Optional[float] = None # Book value per share market_cap: Optional[float] = None # Trading metrics (LongPort only) turnover_rate: Optional[float] = None volume_ratio: Optional[float] = None change_rate: Optional[float] = None # Share info (LongPort) total_shares: Optional[int] = None circulating_shares: Optional[int] = None # Fundamental extras (Yahoo Finance fallback) operating_margin: Optional[float] = None profit_margin: Optional[float] = None roe: Optional[float] = None roa: Optional[float] = None revenue_growth: Optional[float] = None earnings_growth: Optional[float] = None debt_to_equity: Optional[float] = None current_ratio: Optional[float] = None # Analyst data (Yahoo Finance only) recommendation: Optional[str] = None target_price: Optional[float] = None num_analysts: Optional[int] = None # Earnings (Yahoo Finance only) earnings_history: Optional[object] = None # DataFrame # Price history (Yahoo Finance primary, LongPort fallback) price_history: Optional[object] = None # DataFrame # Metadata data_sources: list = field(default_factory=list) # ["longport", "yahoo"] def _detect_market(symbol: str) -> tuple[str, str]: """Detect market from symbol format. Returns: (normalized_symbol, market) Examples: "O.US" -> ("O.US", "US") "AAPL" -> ("AAPL.US", "US") "823.HK" -> ("823.HK", "HK") "000001.SZ" -> ("000001.SZ", "CN") """ symbol = symbol.upper().strip() if symbol.endswith(".US"): return symbol, "US" elif symbol.endswith(".HK"): return symbol, "HK" elif symbol.endswith(".SZ") or symbol.endswith(".SH"): return symbol, "CN" elif symbol.endswith("-USD"): return symbol, "CRYPTO" else: # Assume US stock if no suffix return f"{symbol}.US", "US" def fetch_from_longport(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]: """Fetch data from LongPort SDK.""" ctx = _get_longport_context() if ctx is None: return None try: from longport.openapi import CalcIndex normalized, market = _detect_market(symbol) if verbose: print(f" [LongPort] Fetching {normalized}...", file=sys.stderr) # Get quote quotes = ctx.quote([normalized]) if not quotes: return None q = quotes[0] # Get static info (EPS, BPS, shares) static_infos = ctx.static_info([normalized]) static = static_infos[0] if static_infos else None # Get calc indexes (PE, PB, dividend yield, market cap) indexes = [ CalcIndex.PeTtmRatio, CalcIndex.PbRatio, CalcIndex.DividendRatioTtm, CalcIndex.TotalMarketValue, CalcIndex.TurnoverRate, CalcIndex.VolumeRatio, CalcIndex.ChangeRate, ] calc_results = ctx.calc_indexes([normalized], indexes) calc = calc_results[0] if calc_results else None # Build result data = UnifiedStockData( symbol=normalized, name=getattr(static, 'name_en', '') or getattr(static, 'name_cn', ''), market=market, currency=getattr(static, 'currency', ''), # Price last_price=q.last_done, prev_close=q.prev_close, open_price=q.open, high=q.high, low=q.low, volume=q.volume, turnover=q.turnover, # Valuation pe_ttm=getattr(calc, 'pe_ttm_ratio', None), pb=getattr(calc, 'pb_ratio', None), dividend_yield=getattr(calc, 'dividend_ratio_ttm', None), eps_ttm=getattr(static, 'eps_ttm', None), bps=getattr(static, 'bps', None), market_cap=getattr(calc, 'total_market_value', None), # Trading turnover_rate=getattr(calc, 'turnover_rate', None), volume_ratio=getattr(calc, 'volume_ratio', None), change_rate=getattr(calc, 'change_rate', None), # Shares total_shares=getattr(static, 'total_shares', None), circulating_shares=getattr(static, 'circulating_shares', None), # Source data_sources=["longport"], ) return data except Exception as e: if verbose: print(f" [LongPort] Error: {e}", file=sys.stderr) return None def fetch_from_yahoo(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]: """Fetch data from Yahoo Finance (fallback for missing fields).""" yf = _get_yfinance() if yf is None: return None try: # Convert symbol format for Yahoo yahoo_symbol = symbol.replace(".US", "") if symbol.endswith(".HK"): yahoo_symbol = symbol # Yahoo uses .HK suffix if verbose: print(f" [Yahoo] Fetching {yahoo_symbol}...", file=sys.stderr) stock = yf.Ticker(yahoo_symbol) info = stock.info if not info: return None # Get price history try: price_history = stock.history(period="1y") except Exception: price_history = None # Get earnings history try: earnings_history = stock.earnings_dates except Exception: earnings_history = None # Build result with Yahoo data data = UnifiedStockData( symbol=symbol, name=info.get("shortName", "") or info.get("longName", ""), market="US" if not symbol.endswith(".HK") else "HK", currency=info.get("currency", ""), # Price last_price=info.get("regularMarketPrice") or info.get("currentPrice"), prev_close=info.get("regularMarketPreviousClose"), open_price=info.get("regularMarketOpen"), high=info.get("regularMarketDayHigh"), low=info.get("regularMarketDayLow"), volume=info.get("regularMarketVolume"), # Valuation pe_ttm=info.get("trailingPE"), pb=info.get("priceToBook"), dividend_yield=info.get("dividendYield"), eps_ttm=info.get("trailingEps"), market_cap=info.get("marketCap"), # Fundamentals (Yahoo extras) operating_margin=info.get("operatingMargins"), profit_margin=info.get("profitMargins"), roe=info.get("returnOnEquity"), roa=info.get("returnOnAssets"), revenue_growth=info.get("revenueGrowth"), earnings_growth=info.get("earningsGrowth"), debt_to_equity=info.get("debtToEquity"), current_ratio=info.get("currentRatio"), # Analyst recommendation=info.get("recommendationKey"), target_price=info.get("targetMeanPrice"), num_analysts=info.get("numberOfAnalystOpinions"), # History earnings_history=earnings_history, price_history=price_history, # Source data_sources=["yahoo"], ) return data except Exception as e: if verbose: print(f" [Yahoo] Error: {e}", file=sys.stderr) return None def merge_data(longport_data: Optional[UnifiedStockData], yahoo_data: Optional[UnifiedStockData]) -> Optional[UnifiedStockData]: """Merge data from both sources, LongPort takes priority.""" if longport_data is None and yahoo_data is None: return None if longport_data is None: return yahoo_data if yahoo_data is None: return longport_data # Merge: LongPort primary, Yahoo fills gaps merged = longport_data # Fill Yahoo extras for field in ['operating_margin', 'profit_margin', 'roe', 'roa', 'revenue_growth', 'earnings_growth', 'debt_to_equity', 'current_ratio', 'recommendation', 'target_price', 'num_analysts', 'earnings_history', 'price_history']: if getattr(merged, field) is None: val = getattr(yahoo_data, field) if val is not None: setattr(merged, field, val) # Track sources merged.data_sources = ["longport", "yahoo"] return merged def fetch_stock_data_unified(symbol: str, verbose: bool = False) -> Optional[UnifiedStockData]: """Main entry point: fetch stock data with LongPort primary, Yahoo fallback. Args: symbol: Stock symbol (e.g., "O", "O.US", "823.HK", "AAPL") verbose: Print debug info Returns: UnifiedStockData or None """ normalized, market = _detect_market(symbol) if verbose: print(f"\nFetching {normalized} ({market})...", file=sys.stderr) # 1. Try LongPort first (for all markets) longport_data = fetch_from_longport(normalized, verbose) # 2. For US stocks, also try Yahoo for extras yahoo_data = None if market == "US": yahoo_data = fetch_from_yahoo(normalized, verbose) # 3. Merge results result = merge_data(longport_data, yahoo_data) if result and verbose: print(f" Sources: {result.data_sources}", file=sys.stderr) print(f" PE: {result.pe_ttm}, PB: {result.pb}, Yield: {result.dividend_yield}%", file=sys.stderr) return result # Convenience function for backward compatibility def fetch_stock_data(symbol: str, verbose: bool = False): """Backward compatible wrapper.""" return fetch_stock_data_unified(symbol, verbose) if __name__ == "__main__": # Test symbols = ["O", "823.HK", "MAIN"] for sym in symbols: data = fetch_stock_data_unified(sym, verbose=True) if data: print(f"\n{data.symbol}:") print(f" Name: {data.name}") print(f" Price: {data.last_price} {data.currency}") print(f" PE: {data.pe_ttm}, PB: {data.pb}") print(f" Dividend Yield: {data.dividend_yield}%") print(f" EPS: {data.eps_ttm}, BPS: {data.bps}") print(f" Market Cap: {data.market_cap:,.0f}" if data.market_cap else "") print(f" Sources: {data.data_sources}")