diff --git a/mo_bridge.py b/mo_bridge.py new file mode 100644 index 0000000..80572cd --- /dev/null +++ b/mo_bridge.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +mo_bridge.py — MoFin ↔ DSA 集成桥接 + +在 MoFin 的定时分析流程(cron_to_xmpp.py)中, +在 LLM 分析 prompt 之前注入 DSA 的宏观情报。 + +用法: + from mo_bridge import enrich_analysis_context + + # 在 LLM 分析前调用 + context = enrich_analysis_context() + if context: + prompt += f"\n\n## 今日大盘背景\n{context}" + +依赖: + 需要 DSA 源码 + 依赖(pip install litellm akshare yfinance 等) + 未安装时优雅降级,不影响 MoFin 正常运行。 +""" + +import sys +import os +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +# DSA 源码路径 +_DSA_BASE = Path(__file__).resolve().parent.parent / "daily-stock-analysis" / "ZhuLinsen-daily_stock_analysis-a448886" + +_HAS_DSA = _DSA_BASE.is_dir() and (_DSA_BASE / "data_provider" / "base.py").exists() + + +def enrich_analysis_context(region: str = "cn") -> str: + """从 DSA 获取市场背景和新闻舆情,注入 MoFin 分析上下文。 + + Args: + region: cn/hk/us/both — 分析哪个市场 + + Returns: + str: Markdown 格式的市场背景文本(可直接注入分析 prompt) + 如果 DSA 不可用,返回空字符串 + """ + parts = [] + + # 1. 大盘复盘 + market_text = get_market_review(region) + if market_text: + parts.append(f"## 今日大盘背景\n{market_text}") + + # 2. 搜索舆情(如果有 DSA search_service) + news_text = get_news_context() + if news_text: + parts.append(f"## 今日重要新闻\n{news_text}") + + return "\n\n".join(parts) + + +def get_market_review(region: str = "cn") -> str | None: + """获取 DSA 市场复盘摘要""" + if not _HAS_DSA: + return None + + try: + sys.path.insert(0, str(_DSA_BASE)) + + # 尝试从 DSA 的本地缓存中读取最近的市场复盘 + from src.services.daily_market_context import DailyMarketContextService + + # 先看本地是否有缓存 + cache_dir = _DSA_BASE / "data" / "market_review" + if cache_dir.exists(): + files = sorted(cache_dir.glob("*.md"), key=os.path.getmtime, reverse=True) + if files: + content = files[0].read_text(encoding="utf-8") + # 只取摘要部分(前 500 字) + lines = content.split("\n") + summary_lines = [] + for line in lines: + if len(line.strip()) > 5: + summary_lines.append(line) + if len(summary_lines) >= 20: + break + return "\n".join(summary_lines) + + # 如果没有缓存,尝试实时获取(需要 DSA 完整配置) + logger.debug("未找到 DSA 市场复盘缓存,跳过") + return None + + except Exception as e: + logger.debug("获取 DSA 市场复盘失败: %s", e) + return None + finally: + # 清理 sys.path + if str(_DSA_BASE) in sys.path: + sys.path.remove(str(_DSA_BASE)) + + +def get_news_context() -> str | None: + """搜索今日重要财经新闻""" + # 预留接口:待 DSA 依赖安装后实现 + # 目前 MoFin 的 mofin_news.py 已覆盖基本新闻需求 + return None + + +def get_stock_fundamentals(code: str) -> dict | None: + """通过 DSA 获取股票基本面数据""" + if not _HAS_DSA: + return None + + try: + sys.path.insert(0, str(_DSA_BASE)) + from mo_provider import MoDataProvider + provider = MoDataProvider() + return provider.get_fundamentals(code) + except Exception as e: + logger.debug("获取 %s 基本面失败: %s", code, e) + return None + finally: + if str(_DSA_BASE) in sys.path: + sys.path.remove(str(_DSA_BASE)) + + +# ── 便捷入口 ────────────────────────────────────────────────────────── + +def quick_summary() -> str: + """快速获取今日分析上下文(单次调用)""" + return enrich_analysis_context() + + +# ── 自检 ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"DSA 可用: {_HAS_DSA}") + print(f"DSA 路径: {_DSA_BASE}") + + if _HAS_DSA: + context = enrich_analysis_context() + if context: + print(f"\n=== 市场上下文 ({len(context)} 字符) ===") + print(context[:1000]) + else: + print("\n无可用市场上下文(DSA 缓存为空)") + else: + print("\nDSA 不可用,跳过。部署后需安装依赖:") + print(" pip install litellm akshare yfinance baostock") diff --git a/mo_config.py b/mo_config.py new file mode 100644 index 0000000..a919578 --- /dev/null +++ b/mo_config.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +mo_config.py — MoFin 统一配置管理(单例模式) + +替代 MoFin 中散落在各文件的硬编码路径和常量。 + +⚠️ 铁律:所有 MoFin 模块必须从此处获取路径和配置,严禁硬编码。 + 之前:DATA_DIR = "/home/hmo/web-dashboard/data" (散落在 10+ 文件中) + 现在:from mo_config import config; config.data_dir + +用法: + from mo_config import config + portfolio_path = config.data_dir / "portfolio.json" +""" + +import os +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class MoConfig: + """MoFin 全局配置单例""" + + # ── 路径 ────────────────────────────────────────────────────── + # 项目根目录 + project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve()) + + # 数据目录(portfolio.json, decisions.json 等) + data_dir: Path = field(default_factory=lambda: Path( + os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data") + )) + + # SQLite 数据库路径 + db_path: Path = field(default=None) + + # 缓存目录 + cache_dir: Path = field(default_factory=lambda: Path.home() / ".cache" / "mofin") + + # Hermes 状态目录 + hermes_dir: Path = field(default_factory=lambda: Path.home() / ".hermes") + + # ── 关键数据文件路径 ────────────────────────────────────────── + + @property + def portfolio_path(self) -> Path: + return self.data_dir / "portfolio.json" + + @property + def decisions_path(self) -> Path: + return self.data_dir / "decisions.json" + + @property + def watchlist_path(self) -> Path: + return self.data_dir / "watchlist.json" + + @property + def price_events_path(self) -> Path: + return self.data_dir / "price_events.json" + + @property + def live_prices_path(self) -> Path: + return self.data_dir / "live_prices.json" + + @property + def evaluation_input_path(self) -> Path: + return self.data_dir / "evaluation_input.json" + + @property + def multi_tf_cache_path(self) -> Path: + return self.data_dir / "multi_tf_cache.json" + + @property + def price_history_path(self) -> Path: + return self.data_dir / "price_history.json" + + # ── DB 路径(懒加载) ──────────────────────────────────────── + + def _get_db_path(self) -> Path: + if self.db_path is None: + self.db_path = self.data_dir / "mofin.db" + return self.db_path + + # ── 汇率 ────────────────────────────────────────────────────── + + hk_rate_fallback: float = 0.87 # 港币→人民币 fallback 汇率 + + # ── 服务配置 ────────────────────────────────────────────────── + + port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8899"))) + + tdx_relay_url: str = field( + default_factory=lambda: os.environ.get("TDX_RELAY_URL", "http://localhost:8080") + ) + + xmpp_agent_host: str = field( + default_factory=lambda: os.environ.get("XMPP_AGENT_HOST", "localhost") + ) + + xmpp_agent_port: int = field( + default_factory=lambda: int(os.environ.get("XMPP_AGENT_PORT", "5801")) + ) + + # ── DSA 集成 ────────────────────────────────────────────────── + + dsa_enabled: bool = field( + default_factory=lambda: os.environ.get("DSA_ENABLED", "false").lower() == "true" + ) + + dsa_base_dir: Path = field(default_factory=lambda: Path( + os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "daily-stock-analysis", + "ZhuLinsen-daily_stock_analysis-a448886" + )) + )) + + # ── 数据新鲜度 ──────────────────────────────────────────────── + + market_hours_max_stale_min: int = 5 # 盘中最大过期时间(分钟) + off_hours_max_stale_min: int = 120 # 盘后最大过期时间(分钟) + + # ── 验证 ────────────────────────────────────────────────────── + + def validate(self) -> List[str]: + """验证配置,返回问题列表""" + issues = [] + + if not self.data_dir.exists(): + issues.append(f"数据目录不存在: {self.data_dir}") + + if not self.portfolio_path.exists(): + issues.append(f"portfolio.json 不存在: {self.portfolio_path}") + + if not self.decisions_path.exists(): + issues.append(f"decisions.json 不存在: {self.decisions_path}") + + return issues + + def ensure_dirs(self): + """确保必要的目录存在""" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.hermes_dir.mkdir(parents=True, exist_ok=True) + + # ── 输出 ────────────────────────────────────────────────────── + + def summary(self) -> str: + """打印配置摘要""" + lines = [ + "=== MoFin 配置 ===", + f"项目目录: {self.project_dir}", + f"数据目录: {self.data_dir} (存在: {self.data_dir.exists()})", + f"DB路径: {self._get_db_path()} (存在: {self._get_db_path().exists()})", + f"端口: {self.port}", + f"TDX Relay: {self.tdx_relay_url}", + f"DSA 集成: {'启用' if self.dsa_enabled else '关闭'}", + f"港币汇率 fallback: {self.hk_rate_fallback}", + ] + issues = self.validate() + if issues: + lines.append(f"\n⚠️ 配置问题 ({len(issues)}):") + for i in issues: + lines.append(f" - {i}") + return "\n".join(lines) + + +# ── 单例 ──────────────────────────────────────────────────────────── + +_config_instance: MoConfig | None = None + + +def get_config() -> MoConfig: + """获取全局配置单例""" + global _config_instance + if _config_instance is None: + _config_instance = MoConfig() + return _config_instance + + +# 便捷别名 +config = property(lambda self: get_config()) + + +# ── 模块级便捷访问 ────────────────────────────────────────────────── + +def data_dir() -> Path: + return get_config().data_dir + +def ensure_dirs(): + get_config().ensure_dirs() + + +# ── 向后兼容:导出常用路径常量 ────────────────────────────────────── +# 让旧代码可以通过熟悉的变量名访问路径 + +def _lazy(attr): + """懒加载属性,首次访问时从 config 获取""" + return getattr(get_config(), attr) + +# 为兼容旧代码导出以下变量 +PORTFOLIO_PATH = None # 改用 config.portfolio_path +DECISIONS_PATH = None # 改用 config.decisions_path +WATCHLIST_PATH = None # 改用 config.watchlist_path + + +# ── 自检 ──────────────────────────────────────────────────────────── + +if __name__ == "__main__": + cfg = get_config() + print(cfg.summary()) diff --git a/mo_models.py b/mo_models.py new file mode 100644 index 0000000..91ad301 --- /dev/null +++ b/mo_models.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +""" +mo_models.py — MoFin 唯一数据模型(Single Source of Truth) + +⚠️ 铁律:MoFin 中所有以下操作必须走这个文件,严禁各自实现: + 1. 判断港股 — 用 is_hk_stock(code) + 2. 计算总资产 — 用 calc_total_assets(pf) + 3. 获取港币汇率 — 用 get_hk_rate() + 4. 币种转换 — 用 to_cny(price, code) + +创建日期: 2026-06-29 +原因: 之前 total_assets 在 6+ 文件中各自计算,公式不一致(3个漏了 frozen_cash); + is_hk_stock 有 3 种不同实现,存在误判风险; + hk_rate 在多个文件中硬编码不同值(0.866/0.87/0.8664/0.8700)。 +""" + +import sys +import os + +# ── 港股检测 ──────────────────────────────────────────────────────── + +def is_hk_stock(code): + """判断是否为港股。 + + 规则:港股代码为5位数字,以0或1开头。 + 例:00700(腾讯), 01888(建滔积层板), 00981(中芯国际) + + 排除: + - A股6位代码如 000657(中钨高新) — len==6 不会被误判 + - 美股字母代码如 AAPL + - 带前缀的代码如 hk00700 → 自动去前缀后判断 + """ + code = (str(code or '')).strip().upper() + # 去常见前缀 + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + # 港股: 5位数字, 0或1开头 + return len(code) == 5 and code.isdigit() and code[0] in ('0', '1') + + +def is_a_stock(code): + """判断是否为A股(沪深京)""" + code = (str(code or '')).strip().upper() + for prefix in ('SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + if len(code) == 6 and code.isdigit(): + if code.startswith(('0', '3', '6')): + return True + if code.startswith(('4', '8', '9')): + return True # 北交所/科创板 + return False + + +def normalize_code(code): + """归一化股票代码:去市场前缀,去后缀,统一大写""" + code = (str(code or '')).strip().upper() + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + return code + + +# ── 港币汇率 ────────────────────────────────────────────────────────── + +def get_hk_rate(): + """获取 HKD→CNY 汇率。优先用 hk_rate 模块(支持API+缓存),失败回退 0.87""" + try: + from hk_rate import hkd_to_cny + return hkd_to_cny() + except Exception: + pass + # 最后的兜底 + return 0.87 + + +def to_cny(price, code): + """如果 code 是港股,把 price 从 HKD 转为 CNY;否则原样返回""" + if price is None or price == 0: + return price + if is_hk_stock(code): + return round(float(price) * get_hk_rate(), 2) + return price + + +# ── 总资产计算(唯一公式) ──────────────────────────────────────────── + +def calc_total_mv(holdings): + """计算持仓总市值(所有价格已为 CNY)""" + return round(sum( + (h.get('shares', 0) or 0) * (h.get('price', 0) or 0) + for h in (holdings or []) + ), 2) + + +def calc_total_assets(pf): + """ + 计算总资产 = 持仓市值 + 可用现金 + 冻结资金 + + 这是 MoFin 中 total_assets 的 **唯一正确公式**。 + 所有文件必须调用此函数,严禁各自实现。 + + Args: + pf: dict,包含 holdings、cash、frozen_cash 字段 + + Returns: + float: 总资产(人民币) + """ + total_mv = calc_total_mv(pf.get('holdings', [])) + cash = float(pf.get('cash', 0) or 0) + frozen = float(pf.get('frozen_cash', 0) or 0) + return round(total_mv + cash + frozen, 2) + + +def calc_position_pct(pf): + """计算仓位百分比""" + total = calc_total_assets(pf) + if total > 0: + total_mv = calc_total_mv(pf.get('holdings', [])) + return round(total_mv / total * 100, 2) + return 0 + + +# ── 数据验证 ────────────────────────────────────────────────────────── + +def validate_portfolio(pf): + """验证 portfolio.json 数据一致性,返回 issues 列表""" + issues = [] + holdings = pf.get('holdings', []) + + # 1. 总资产校验 + stored = pf.get('total_assets', 0) + calculated = calc_total_assets(pf) + if stored > 0 and abs(stored - calculated) / max(stored, 1) > 0.01: + issues.append( + f"total_assets 不匹配: 存储{stored:.2f} ≠ 计算{calculated:.2f}" + f" (市值{calc_total_mv(holdings):.2f}+现金{pf.get('cash',0):.2f}+冻结{pf.get('frozen_cash',0):.2f})" + ) + + # 2. 币种一致性 + for h in holdings: + code = str(h.get('code', '')) + currency = h.get('currency', h.get('_currency', '')) + if is_hk_stock(code) and currency == 'HKD': + issues.append( + f"⚠️ 港股{code}({h.get('name','?')}) currency=HKD," + f"portfolio.json 应全部存 CNY" + ) + + # 3. 零股检查 + for h in holdings: + if (h.get('shares', 0) or 0) <= 0 and h.get('code'): + issues.append(f"持仓{h.get('code')}({h.get('name','?')}) 股数为0或负数") + + return issues + + +# ── 向后兼容别名 ────────────────────────────────────────────────────── + +# 让旧代码中散落的 is_hk_stock 引用也能正确工作 +__all__ = [ + 'is_hk_stock', + 'is_a_stock', + 'normalize_code', + 'get_hk_rate', + 'to_cny', + 'calc_total_mv', + 'calc_total_assets', + 'calc_position_pct', + 'validate_portfolio', +] + +# 模块自检 +if __name__ == '__main__': + # 测试 is_hk_stock + test_cases = [ + ('00700', True), # 腾讯 + ('01888', True), # 建滔积层板 + ('000657', False), # 中钨高新 A股 + ('600519', False), # 茅台 A股 + ('AAPL', False), # 苹果 美股 + ('hk00700', True), # 带前缀港股 + ('SH600519', False), # 带前缀A股 + ] + print("=== is_hk_stock 测试 ===") + all_ok = True + for code, expected in test_cases: + result = is_hk_stock(code) + status = '✅' if result == expected else '❌' + if result != expected: + all_ok = False + print(f" {status} is_hk_stock('{code}') = {result} (expected {expected})") + + # 测试 calc_total_assets + print("\n=== calc_total_assets 测试 ===") + pf = { + 'holdings': [ + {'code': '00700', 'shares': 100, 'price': 365.0}, + {'code': '600519', 'shares': 200, 'price': 1700.0}, + ], + 'cash': 50000.0, + 'frozen_cash': 10000.0, + } + expected_mv = 100 * 365.0 + 200 * 1700.0 # 36500 + 340000 = 376500 + expected_ta = expected_mv + 50000 + 10000 # 436500 + calc_ta = calc_total_assets(pf) + print(f" total_mv = {calc_total_mv(pf['holdings'])} (expected {expected_mv})") + print(f" total_assets = {calc_ta} (expected {expected_ta})") + print(f" {'✅' if abs(calc_ta - expected_ta) < 0.01 else '❌'} calc_total_assets") + + # 测试 validate + print("\n=== validate_portfolio 测试 ===") + issues = validate_portfolio(pf) + if issues: + for i in issues: + print(f" ⚠️ {i}") + else: + print(" ✅ 无问题") + + print(f"\n{'全部通过 ✅' if all_ok else '有失败 ❌'}") diff --git a/mo_provider.py b/mo_provider.py new file mode 100644 index 0000000..68b483b --- /dev/null +++ b/mo_provider.py @@ -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}") diff --git a/price_monitor.py b/price_monitor.py index 14241aa..e080037 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -10,6 +10,9 @@ import sys import time from datetime import datetime +# ── MoFin unified model ────────────────────────────────────────────── +from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct + DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" @@ -26,10 +29,9 @@ except ImportError: HAS_REASSESS = False try: - from hk_rate import hkd_to_cny - HK_RATE = hkd_to_cny() + HK_RATE = get_hk_rate() except Exception: - HK_RATE = 0.8700 # fallback + HK_RATE = 0.87 # ultimate fallback # 分支系统与情景检测 try: @@ -152,8 +154,8 @@ def refresh_data_prices(): if s['code'] in prices: price, _, change_pct = prices[s['code']] if price > 0: - # 港股:API返回HKD,需转RMB(2026-06-23 bugfix) - if str(s['code']).startswith(('0','1')) and len(str(s['code']))==5: + # 港股:API返回HKD,需转RMB + if is_hk_stock(s['code']): price = round(price * HK_RATE, 2) old = s.get('price', 0) if abs(old - price) > 0.001: @@ -163,16 +165,10 @@ def refresh_data_prices(): changed = True if changed: pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - # 统一计算总资产:持仓市值 + 现金(所有港股价已×HK_RATE转CNY) - pf['total_mv'] = round(sum( - h.get('shares',0) * h.get('price',0) for h in pf.get('holdings',[]) - ), 2) - # total_assets = 持仓市值 + 可用现金 + 冻结资金(缺一不可!2026-06-29 bugfix) - # cash = 可用资金(从截图/导入/成交记录来的,price_monitor不动它) - # frozen_cash = 冻结资金(T+2未交收/挂单占用) - available = float(pf.get('cash', 0) or 0) - frozen = float(pf.get('frozen_cash', 0) or 0) - pf['total_assets'] = round(pf['total_mv'] + available + frozen, 2) + # 统一计算总资产(mo_models 唯一公式) + pf['total_mv'] = calc_total_mv(pf.get('holdings', [])) + pf['total_assets'] = calc_total_assets(pf) + pf['position_pct'] = calc_position_pct(pf) json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) elif pf.get('updated_at'): # 即使价格无变化,每10分钟刷新一次updated_at,防健康检查误报 @@ -190,8 +186,8 @@ def refresh_data_prices(): if s['code'] in prices: price, _, change_pct = prices[s['code']] if price > 0: - # 港股:API返回HKD,需转RMB(2026-06-23 bugfix) - if str(s['code']).startswith(('0','1')) and len(str(s['code']))==5: + # 港股:API返回HKD,需转RMB + if is_hk_stock(s['code']): price = round(price * HK_RATE, 2) old = s.get('price', 0) if abs(old - price) > 0.001: @@ -203,28 +199,17 @@ def refresh_data_prices(): wl['updated_at'] = datetime.now().isoformat() json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) - # --- 汇总值重算(2026-06-29 bugfix: 之前price_monitor只更新个股价,不更新汇总)--- + # --- 汇总值重算(使用 mo_models 唯一公式)--- try: - live_market_value = sum( - h.get('shares', 0) * h.get('price', 0) - for h in pf.get('holdings', []) - ) - - # 现金:绝不重算。保留上次的值(来自截图/导入/手动修改)。 - # 2026-06-29 bugfix v2: 之前price_monitor用available_cash+frozen_cash重算现金, - # 但截图确认的9.2万被旧冻结数据(3.9万)覆盖=113k,导致cash来回跳 - # 修正:price_monitor只更新market_value,不碰cash - + live_market_value = calc_total_mv(pf.get('holdings', [])) old_mv = pf.get('total_mv', 0) + if abs(old_mv - live_market_value) > 0.01: pf['total_mv'] = round(live_market_value, 2) - # total_assets = 持仓市值 + 可用现金 + 冻结资金(重复!同步上一处公式) - available = float(pf.get('cash', 0) or 0) - frozen = float(pf.get('frozen_cash', 0) or 0) - pf['total_assets'] = round(live_market_value + available + frozen, 2) + pf['total_assets'] = calc_total_assets(pf) if pf['total_assets'] > 0: - pf['position_pct'] = round(live_market_value / pf['total_assets'] * 100, 2) + pf['position_pct'] = calc_position_pct(pf) pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) except Exception as e: diff --git a/scripts/holdings_reconciliation.py b/scripts/holdings_reconciliation.py index 157951b..c04b27f 100644 --- a/scripts/holdings_reconciliation.py +++ b/scripts/holdings_reconciliation.py @@ -89,13 +89,11 @@ def main(): dec["decisions"].append(stub) changes.append(f" {stub['name']}({code}): decisions新增持仓({h['shares']}股,来自portfolio)") - # 3. Recalculate total_assets in portfolio - stock_value = 0 - for h in pf.get("holdings", []): - if h.get("shares", 0) > 0 and h.get("price", 0) > 0: - stock_value += h["shares"] * h["price"] - cash = pf.get("cash", 0) - total_assets = round(stock_value + cash, 2) + # 3. Recalculate total_assets in portfolio (use mo_models for unified formula) + import sys, os + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_models import calc_total_assets + total_assets = calc_total_assets(pf) dec_total = 0 for d in dec.get("decisions", []): if d.get("shares", 0) > 0 and d.get("price", 0) > 0: diff --git a/scripts/import_holding_xls.py b/scripts/import_holding_xls.py index 13d9b6b..0865474 100755 --- a/scripts/import_holding_xls.py +++ b/scripts/import_holding_xls.py @@ -88,9 +88,10 @@ def main(): print(" holding文件不含现金行,必须手动提供。可以用:") print(f" python3 import_holding_xls.py --cash 73758.0") - # Use provided values or calculate + # Use provided values or calculate (unified formula includes frozen_cash) if total_assets <= 0: - total_assets = total_mv_cny + cash + frozen_cash = float(args.get('frozen', pf.get('frozen_cash', 0)) or 0) + total_assets = total_mv_cny + cash + frozen_cash if market_value <= 0: market_value = round(total_mv_cny, 2) diff --git a/scripts/stale_push_wlin.py b/scripts/stale_push_wlin.py index d9b922e..5be42c6 100644 --- a/scripts/stale_push_wlin.py +++ b/scripts/stale_push_wlin.py @@ -20,6 +20,26 @@ import threading import time from datetime import datetime, time +# ── MoFin unified model import ────────────────────────────────────── +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +try: + from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets + _USE_MO_MODELS = True +except ImportError: + _USE_MO_MODELS = False + def is_hk_stock(code): + code = str(code or '').strip().upper() + return len(code) == 5 and code.isdigit() and code[0] in ('0', '1') + def get_hk_rate(): + return 0.87 + def to_cny(price, code): + if price is None: return price + if is_hk_stock(code): return round(float(price) * get_hk_rate(), 2) + return price + def calc_total_assets(pf): + total_mv = sum((h.get('shares',0) or 0) * (h.get('price',0) or 0) for h in pf.get('holdings',[])) + return round(total_mv + (pf.get('cash',0) or 0) + (pf.get('frozen_cash',0) or 0), 2) + # 市场时段检查 _MARKET_HOURS = { 'ashare': (time(9, 30), time(15, 0)), @@ -246,14 +266,9 @@ def hk_lot_size(code): def lot_cost(code, price): if str(code).startswith("688"): return 200 * price - elif len(str(code)) == 5: + elif is_hk_stock(code): lot = hk_lot_size(code) - try: - sys.path.insert(0, '/home/hmo/MoFin') - from hk_rate import hkd_to_cny - rate = hkd_to_cny() - except Exception: - rate = 0.87 + rate = get_hk_rate() return int(lot * price * rate) else: return 100 * price @@ -487,13 +502,8 @@ def main(): # 直接取 portfolio.json 的总资产(导入时已做港币→人民币换算) total_assets = pf.get("total_assets", 0) or 0 if total_assets <= 0: - # fallback: 手动算 - for h in pf.get("holdings", []): - mv = h.get("shares", 0) * h.get("price", 0) - if len(str(h.get("code", ""))) <= 5: # 港股 - mv *= 0.866 - total_assets += mv - total_assets += available_cash + # fallback: use unified calc_total_assets from mo_models + total_assets = calc_total_assets(pf) except Exception: total_assets = available_cash * 5 # fallback @@ -599,9 +609,7 @@ def main(): if hs <= 0 or hp <= 0: continue hmv = hs * hp - h_code = str(h.get("code", "")) - if len(h_code) <= 5: - hmv *= 0.866 # approximate HKD→CNY + # 港股价格已是 CNY(price_monitor 写入时已转),不需要再乘汇率 hpl_pct = (hp - hc) / hc * 100 if hc else 0 # 6维全面评分(越低越差,越建议卖) diff --git a/scripts/stock_scorer.py b/scripts/stock_scorer.py index 18da6fe..979fd28 100644 --- a/scripts/stock_scorer.py +++ b/scripts/stock_scorer.py @@ -136,14 +136,9 @@ def rank_by_outlook(holdings_list, decisions_data): return results -def is_hk_stock(code): - """判断是否为港股(港股通标的代码通常5位)""" - return len(str(code)) <= 5 - - -def is_a_stock(code): - """判断是否为A股(6位代码)""" - return len(str(code)) == 6 +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mo_models import is_hk_stock, is_a_stock def settlement_delay_note(sell_code, buy_code): diff --git a/server.py b/server.py index 84f6ccf..6d32660 100644 --- a/server.py +++ b/server.py @@ -957,7 +957,9 @@ def upload_confirm(): if summary.get("total_assets"): existing["total_assets"] = float(summary["total_assets"]) else: - existing["total_assets"] = existing["stock_value"] + existing.get("cash", 0) + # Use unified formula (includes frozen_cash) + from mo_models import calc_total_assets + existing["total_assets"] = calc_total_assets(existing) if summary.get("day_pnl"): existing["day_pnl"] = float(summary["day_pnl"]) existing["updated_at"] = datetime.now().isoformat() diff --git a/strategy_lifecycle.py b/strategy_lifecycle.py index b1bac13..ea812b6 100644 --- a/strategy_lifecycle.py +++ b/strategy_lifecycle.py @@ -425,7 +425,7 @@ def batch_fetch_prices(codes): def get_price_tencent(code): """获取实时价格,港股转CNY统一存CNY""" try: - from currency_utils import to_cny, is_hk_stock + from mo_models import to_cny, is_hk_stock except ImportError: to_cny = lambda v, r=None: v is_hk_stock = lambda c: len(str(c).strip()) == 5 and str(c).strip().isdigit()