- Trading skills (OKX, dividend, lottery, quantitative) - Creative skills (ASCII art, diagrams, video) - Development skills (GitHub, debugging, TDD) - Research skills (arXiv, blog monitoring) - Productivity skills (email, documents, notes) - MCP integration skills - Custom user skills
384 lines
13 KiB
Python
384 lines
13 KiB
Python
#!/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}")
|