refactor: phase 0-2 MoFin architecture reform — single source of truth
Phase 0 (止血):
- mo_models.py: unified calc_total_assets(), is_hk_stock(), get_hk_rate() — single source of truth
- Fixed 3 files missing frozen_cash: holdings_reconciliation, server, import_holding_xls
- Fixed stale_push_wlin: unified is_hk_stock detection, removed hardcoded 0.866
- Fixed price_monitor: consolidated 2 duplicate total_assets blocks into mo_models calls
- Fixed stock_scorer: replaced broken len()<=5 is_hk_stock heuristic
- Fixed strategy_lifecycle: replaced non-existent currency_utils import with mo_models
Phase 1 (DSA adapter):
- mo_provider.py: wraps DSA DataFetcherManager (16 fetchers, auto-fallback)
- TDX relay as primary, DSA as backup for realtime/kline/news/fundamentals
Phase 2 (Integration):
- mo_bridge.py: injects DSA market review + news context into MoFin analysis prompts
- Graceful degradation if DSA not installed
Infrastructure:
- mo_config.py: centralized Config singleton replacing scattered hardcoded paths
- All 11 changed files pass python compile check
Impact: total_assets now computed in ONE place (mo_models).
is_hk_stock now ONE implementation (no more false negatives).
HK rate now ONE source (hk_rate API → cache → 0.87 fallback).
No more hardcoded 0.866/0.8664/0.8700 divergence.
This commit is contained in:
+315
@@ -0,0 +1,315 @@
|
||||
#!/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 源码路径(相对于 MoFin 项目)
|
||||
_DSA_BASE = os.path.normpath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "daily-stock-analysis",
|
||||
"ZhuLinsen-daily_stock_analysis-a448886"
|
||||
))
|
||||
|
||||
_HAS_DSA = os.path.isdir(_DSA_BASE)
|
||||
|
||||
|
||||
# ── 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}")
|
||||
Reference in New Issue
Block a user