#!/usr/bin/env python3 """ mo_provider.py — MoFin 统一数据源适配器 封装 DSA (daily_stock_analysis) 的数据源层作为 MoFin 的备份/增强数据管道。 架构: 主数据源:TDX Relay(通達信实时行情,走招商证券 7727 服务器) 备份数据源:DSA DataFetcherManager(16 个 fetcher,自动 fallback) 用法: from mo_provider import MoDataProvider provider = MoDataProvider() # 获取实时行情(TDX 优先,失败 → Tencent API → DSA fallback) realtime = provider.get_realtime("00700") # 获取 K 线数据(优先本地缓存,失败 → DSA) kline = provider.get_kline("600519", period="daily") # 新闻搜索(DSA 的 search_service) news = provider.search_news("腾讯控股") 依赖: DSA 代码需在 ../../daily-stock-analysis/ZhuLinsen-daily_stock_analysis-a448886/ 安装 DSA 依赖: pip install -r ../../daily-stock-analysis/...requirements.txt """ import sys import os import json import logging from datetime import datetime logger = logging.getLogger(__name__) # ── 路径配置 ───────────────────────────────────────────────────────── # DSA 源码路径(按优先级尝试) _DSA_CANDIDATES = [ "/home/hmo/daily-stock-analysis", # 服务器部署路径 os.path.normpath(os.path.join( # 本地开发路径 os.path.dirname(os.path.abspath(__file__)), "..", "daily-stock-analysis", "ZhuLinsen-daily_stock_analysis-a448886" )), ] _DSA_BASE = None for _c in _DSA_CANDIDATES: if os.path.isdir(_c) and os.path.isfile(os.path.join(_c, "data_provider", "base.py")): _DSA_BASE = _c break _HAS_DSA = _DSA_BASE is not None # ── MoDataProvider ─────────────────────────────────────────────────── class MoDataProvider: """MoFin 统一数据获取门面。 - TDX Relay 作为实时行情的主源(港股接近实时) - DSA 作为备份源(当 TDX 不可用时自动 fallback) - 所有数据获取统一走此类,禁止各文件自己实现 """ def __init__(self, tdx_url: str = None): self._dsa_manager = None # 懒加载 DSA DataFetcherManager self._tdx_url = tdx_url or "http://localhost:8080" # TDX relay 地址 # ── DSA 懒加载 ─────────────────────────────────────────────── @property def has_dsa(self) -> bool: """DSA 数据源是否可用""" return _HAS_DSA def _ensure_dsa(self): """懒加载 DSA DataFetcherManager""" if self._dsa_manager is not None: return self._dsa_manager if not _HAS_DSA: logger.warning("DSA 源码不在 %s,无法使用备份数据源", _DSA_BASE) return None try: sys.path.insert(0, _DSA_BASE) from data_provider.base import DataFetcherManager self._dsa_manager = DataFetcherManager() logger.info("DSA DataFetcherManager 已加载(16个数据源)") except Exception as e: logger.warning("加载 DSA DataFetcherManager 失败: %s", e) self._dsa_manager = None return self._dsa_manager # ── 实时行情 ────────────────────────────────────────────────── def get_realtime(self, code: str) -> dict | None: """获取实时行情。 优先级:TDX → Tencent API → DSA fallback Returns: dict with: price, change_pct, volume, name, currency 或 None(所有源均失败) """ # 1. 尝试 TDX Relay try: import urllib.request url = f"{self._tdx_url}/realtime/{code}" req = urllib.request.Request(url) with urllib.request.urlopen(req, timeout=3) as r: data = json.loads(r.read()) if data.get("price"): logger.debug("TDX relay 返回 %s 行情: %.2f", code, data["price"]) return data except Exception: pass # 2. 尝试 Tencent API try: return self._get_tencent_realtime(code) except Exception: pass # 3. DSA fallback dsa = self._ensure_dsa() if dsa: try: # DSA 的 get_realtime_quote result = dsa.get_realtime_quote(code) if result: logger.info("DSA fallback 返回 %s 行情", code) return {"price": result.price, "name": result.name} except Exception as e: logger.debug("DSA fallback 失败: %s", e) logger.warning("所有数据源均无法获取 %s 的行情", code) return None def _get_tencent_realtime(self, code: str) -> dict | None: """通过 Tencent API 获取实时行情""" import urllib.request from mo_models import normalize_code raw = normalize_code(code) # 判断市场 if raw[0] in ('0', '1') and len(raw) == 5: market = "hk" qt_code = f"hk{raw}" elif raw.startswith("6"): market = "sh" qt_code = f"sh{raw}" elif raw.startswith(("0", "3")): market = "sz" qt_code = f"sz{raw}" else: return None url = f"https://qt.gtimg.cn/q={qt_code}" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=5) as r: text = r.read().decode("gbk") # 解析 Tencent 行情格式 parts = text.split("~") if len(parts) > 40: return { "code": code, "name": parts[1], "price": float(parts[3]) if parts[3] else 0, "change_pct": float(parts[32]) if parts[32] else 0, "volume": int(parts[6]) if parts[6] else 0, "market": market, } return None # ── K 线数据 ────────────────────────────────────────────────── def get_kline(self, code: str, period: str = "daily", count: int = 60) -> list | None: """获取 K 线数据。 Args: code: 股票代码 period: daily/weekly/monthly count: 获取条数 Returns: list of dict 或 None """ dsa = self._ensure_dsa() if not dsa: return None try: df = dsa.get_daily_data(code, period=period, limit=count) if df is not None and not df.empty: return df.to_dict("records") except Exception as e: logger.warning("DSA get_kline 失败: %s", e) return None # ── 新闻搜索 ────────────────────────────────────────────────── def search_news(self, query: str, max_results: int = 5) -> list: """通过 DSA 的搜索服务获取新闻。 Args: query: 搜索关键词 max_results: 最多返回条数 Returns: list of dict with: title, url, snippet, date """ dsa = self._ensure_dsa() if not dsa: return [] try: from src.search_service import SearchService service = SearchService() results = service.search(query, limit=max_results) return results[:max_results] if results else [] except Exception as e: logger.debug("DSA news search 失败: %s", e) return [] # ── 大盘分析 ────────────────────────────────────────────────── def get_market_context(self, region: str = "cn") -> str | None: """获取 DSA 的市场复盘摘要。 Args: region: cn/hk/us/both Returns: 市场上下文文本 或 None """ dsa = self._ensure_dsa() if not dsa: return None try: from src.core.market_review import run_market_review from src.config import get_config config = get_config() result = run_market_review(config=config, send_notification=False) if result and hasattr(result, 'report'): return result.report except Exception as e: logger.debug("DSA market review 失败: %s", e) return None # ── 基本面 ──────────────────────────────────────────────────── def get_fundamentals(self, code: str) -> dict | None: """获取股票基本面数据(PE/PB/ROE 等)""" dsa = self._ensure_dsa() if not dsa: return None try: from data_provider.fundamental_adapter import AkshareFundamentalAdapter adapter = AkshareFundamentalAdapter() return adapter.get_fundamentals(code) except Exception: pass return None # ── 单例 ──────────────────────────────────────────────────────────── _provider_instance: MoDataProvider | None = None def get_provider() -> MoDataProvider: """获取 MoDataProvider 单例""" global _provider_instance if _provider_instance is None: _provider_instance = MoDataProvider() return _provider_instance # ── 便捷函数 ──────────────────────────────────────────────────────── def get_realtime(code: str) -> dict | None: """便捷函数:获取实时行情""" return get_provider().get_realtime(code) def get_market_context() -> str | None: """便捷函数:获取大盘上下文""" return get_provider().get_market_context() def search_news(query: str) -> list: """便捷函数:搜索新闻""" return get_provider().search_news(query) # ── 自检 ──────────────────────────────────────────────────────────── if __name__ == "__main__": provider = MoDataProvider() print(f"DSA 可用: {provider.has_dsa}") print(f"DSA 路径: {_DSA_BASE}") if provider.has_dsa: manager = provider._ensure_dsa() print(f"DataFetcherManager: {'已加载' if manager else '加载失败'}") # 测试 Tencent API try: result = provider._get_tencent_realtime("00700") print(f"\nTencent API 测试 (00700): {result}") except Exception as e: print(f"Tencent API 测试失败: {e}")