diff --git a/deploy/profile-scripts/mo_config.py b/deploy/profile-scripts/mo_config.py
index 884018cd..10c9ba6e 100644
--- a/deploy/profile-scripts/mo_config.py
+++ b/deploy/profile-scripts/mo_config.py
@@ -1,231 +1,231 @@
-#!/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
- from mo_data import read_portfolio; data = read_portfolio()
-"""
-
-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())
-
- # 数据目录(mofin.db 等,所有数据只从 DB 读写)
- 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:
- """⚠️ DEPRECATED: 数据已迁至 mofin.db holdings + portfolio_summary 表。"""
- import warnings
- warnings.warn("portfolio_path is deprecated — use mo_data.read_portfolio() for DB data", DeprecationWarning, stacklevel=2)
- return Path()
-
- @property
- def decisions_path(self) -> Path:
- """⚠️ DEPRECATED: 数据已迁至 mofin.db holding_strategies 表。"""
- import warnings
- warnings.warn("decisions_path is deprecated — use mo_data.read_decisions() for DB data", DeprecationWarning, stacklevel=2)
- return Path()
-
- @property
- def watchlist_path(self) -> Path:
- """⚠️ DEPRECATED: 数据已迁至 mofin.db watchlist_stocks 表。"""
- import warnings
- warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2)
- return Path()
-
- @property
- def live_prices_path(self) -> Path:
- """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
- 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:
- """⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。"""
- 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 汇率
-
- # ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─
- # node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier)
- xiaoguo_host: str = "node122"
- xiaoguo_port: int = 18003
-
- @property
- def xiaoguo_url(self) -> str:
- return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}"
-
- @property
- def xiaoguo_api_url(self) -> str:
- return f"{self.xiaoguo_url}/v1/chat/completions"
-
- 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_path 不存在(已废弃): {self.portfolio_path}")
-
- if not self.decisions_path.exists():
- issues.append(f"decisions_path 不存在(已废弃): {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()
-
-
-# ── 向后兼容:导出已废弃的路由常量 ──────────────────────────────────
-# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。
-
-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())
+#!/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
+ from mo_data import read_portfolio; data = read_portfolio()
+"""
+
+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())
+
+ # 数据目录(mofin.db 等,所有数据只从 DB 读写)
+ 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:
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db holdings + portfolio_summary 表。"""
+ import warnings
+ warnings.warn("portfolio_path is deprecated — use mo_data.read_portfolio() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
+
+ @property
+ def decisions_path(self) -> Path:
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db holding_strategies 表。"""
+ import warnings
+ warnings.warn("decisions_path is deprecated — use mo_data.read_decisions() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
+
+ @property
+ def watchlist_path(self) -> Path:
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db watchlist_stocks 表。"""
+ import warnings
+ warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
+
+ @property
+ def live_prices_path(self) -> Path:
+ """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
+ 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:
+ """⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。"""
+ 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 汇率
+
+ # ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─
+ # node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier)
+ xiaoguo_host: str = "node122"
+ xiaoguo_port: int = 18003
+
+ @property
+ def xiaoguo_url(self) -> str:
+ return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}"
+
+ @property
+ def xiaoguo_api_url(self) -> str:
+ return f"{self.xiaoguo_url}/v1/chat/completions"
+
+ 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_path 不存在(已废弃): {self.portfolio_path}")
+
+ if not self.decisions_path.exists():
+ issues.append(f"decisions_path 不存在(已废弃): {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()
+
+
+# ── 向后兼容:导出已废弃的路由常量 ──────────────────────────────────
+# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。
+
+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/deploy/profile-scripts/mofin_health.py b/deploy/profile-scripts/mofin_health.py
index 7e03f725..efdfaf8e 100644
--- a/deploy/profile-scripts/mofin_health.py
+++ b/deploy/profile-scripts/mofin_health.py
@@ -1,976 +1,976 @@
-#!/usr/bin/env python3
-"""mofin_health.py — MoFin 健康监控数据采集
-
-输出JSON供dashboard展示,三个view:
- tab1: 功能树(逐级展开,每节点绿/黄/红)
- tab2: 数据实体表(输入/输出流分析,孤立表报警)
- tab3: 流程/cron映射(状态正常/异常)
-"""
-import json, os, sys, re
-import sqlite3
-from pathlib import Path
-from datetime import datetime, timezone
-from mofin_db import get_conn
-
-DATA_DIR = Path("/home/hmo/MoFin/data")
-WEB_DATA = Path("/home/hmo/web-dashboard/data")
-STATIC_DIR = Path("/home/hmo/web-dashboard/static")
-PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts")
-CRON_FILES = [
- "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json",
- "/home/hmo/.hermes/cron/jobs.json",
-]
-
-# 数据实体作用说明
-TABLES_DESC = {
- "holdings": "当前持仓(权威源)",
- "holding_strategies": "每只股票的完整策略参数",
- "portfolio_summary": "总资产/现金/仓位汇总",
- "portfolio_state": "组合状态快照(只读派生)",
- "strategy_evaluations": "策略重评历史记录",
- "strategy_feedback": "策略效果反馈",
- "watchlist_stocks": "自选股列表",
- "candidates": "潜力股候选池(小果扫描产出)",
- "live_prices": "所有持仓+自选最新实时价",
- "price_events": "价格区间突破事件日志",
- "market_snapshots": "大盘指数快照(每10分)",
- "sector_snapshots": "行业板块数据",
- "sector_signals": "行业信号(趋势检测产出)",
- "signal_news": "信号相关新闻",
- "macro_raw_news": "宏观新闻原始数据",
- "macro_context_log": "宏观上下文(大盘偏向/指数)",
- "stocks": "全量股票代码",
- "stock_daily": "日线行情",
- "stock_weekly": "周线行情",
- "stock_monthly": "月线行情",
- "stock_fundamentals": "基本面数据(PE/PB)",
- "stock_sectors": "股票行业映射",
- "capital_flow_cache": "资金流缓存",
- "xiaoguo_scan_tracker": "小果扫描跟踪",
- "advice_timeline": "建议执行时间线",
- "accuracy_stats": "建议准确率统计",
- "todos": "自愈任务队列",
- "health_check_log": "健康检查日志",
- "cash_log": "资金变动记录",
- "mtf_cache": "多周期均线缓存",
- "state_meta": "系统状态元数据",
-}
-
-JSON_DESC = {
- "decisions.json": "策略决策(DB→JSON同步,兼容层)",
- "portfolio.json": "持仓汇总(兼容层)",
- "market.json": "市场概况数据",
- "xiaoguo_insights.json": "小果分析洞察",
- "candidate_pool.json": "潜力股候选池完整数据",
- "zone_breach.json": "价格区间突破状态",
- "strategy_staleness_report.json": "策略过期报告",
- "alerts.json": "告警列表",
- "macro_risk_state.json": "宏观风险状态(采集器写入)",
- "capital_flow_cache.json": "资金流缓存",
- "multi_tf_cache.json": "多周期均线缓存",
- "macro_context.json": "宏观上下文JSON(旧兼容层)",
- "system_inventory.json": "全量系统清单",
- "mofin_health.json": "健康监控数据",
-}
-
-now = datetime.now()
-
-def load_cron_jobs():
- jobs = []
- seen = set()
- for jf in CRON_FILES:
- profile_tag = "position-analyst" if "position-analyst" in str(jf) else "default"
- try:
- for j in json.load(open(jf)).get("jobs", []):
- jid = j.get("id", "")
- if jid in seen: continue
- seen.add(jid)
- j["profile"] = profile_tag
- jobs.append(j)
- except: pass
- return jobs
-
-def get_db_stats():
- conn = get_conn()
- tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall()
- stats = {}
- for (tname,) in tables:
- cnt = conn.execute(f"SELECT COUNT(*) FROM \"{tname}\"").fetchone()[0]
- stats[tname] = cnt
- conn.close()
- return stats
-
-def scan_data_flows():
- """对每个脚本,扫描它读/写了哪些DB表和JSON文件"""
- flows = {"db_read": {}, "db_write": {}, "json_read": {}, "json_write": {}}
- for py in sorted(PROFILE_SCRIPTS.glob("*.py")):
- name = py.stem
- content = py.read_text(encoding="utf-8", errors="ignore")
- # DB reads: SELECT FROM
- reads = set(re.findall(r'FROM\s+(\w+)', content, re.I))
- reads |= set(re.findall(r'join\s+(\w+)', content, re.I))
- # DB writes: INSERT INTO / UPDATE / DELETE FROM
- writes = set(re.findall(r'INSERT\s+(?:OR\s+\w+\s+)?INTO\s+(\w+)', content, re.I))
- writes |= set(re.findall(r'UPDATE\s+(\w+)', content, re.I))
- writes |= set(re.findall(r'DELETE\s+FROM\s+(\w+)', content, re.I))
- # JSON reads: json.load/open
- json_r = set(re.findall(r'(?:json\.load|open)\s*\(\s*["\']([^"\']+\.json)', content))
- json_w = set(re.findall(r'(?:json\.dump|json\.dumps)\s*\(', content))
- for t in reads: flows["db_read"].setdefault(t, set()).add(name)
- for t in writes: flows["db_write"].setdefault(t, set()).add(name)
- for f in json_r:
- fname = os.path.basename(f)
- flows["json_read"].setdefault(fname, set()).add(name)
- if json_w:
- flows["json_write"].setdefault(name, set()).add(name)
- return {k: {kk: list(vv) for kk, vv in v.items()} for k, v in flows.items()}
-
-def check_scripts():
- """检查每个脚本是否有语法错误或明显问题"""
- issues = {}
- for py in sorted(PROFILE_SCRIPTS.glob("*.py")):
- r = os.system(f"python3 -m py_compile {py} 2>/dev/null")
- issues[py.stem] = "ok" if r == 0 else "syntax_error"
- return issues
-
-
-def match_cron(cron_jobs, name_keywords):
- """匹配cron任务列表,返回匹配的cron信息列表(空格归一化后匹配)"""
- matches = []
- for j in cron_jobs:
- jname = j.get("name", "").replace(" ", "").replace("\u00a0", "") # 去空格再比
- if isinstance(name_keywords, str):
- if name_keywords.replace(" ", "") in jname:
- matches.append(j)
- elif isinstance(name_keywords, (list, tuple)):
- clean_kws = [k.replace(" ", "").replace("\u00a0", "") for k in name_keywords]
- if any(kw in jname for kw in clean_kws):
- matches.append(j)
- elif callable(name_keywords):
- if name_keywords(j):
- matches.append(j)
- # 去重(相同name只保留一条)
- seen = set()
- deduped = []
- for j in matches:
- n = j.get("name", "")
- if n not in seen:
- seen.add(n)
- deduped.append(j)
- return deduped
-
-
-# ── 功能树描述 ──
-NODE_DESC = {
- "数据采集": "从腾讯/东财/小果采集原始行情、新闻、资金流数据",
- "策略分析": "策略评估、新鲜度检查、重评和成长分析",
- "推荐推送": "生成简报、推荐并推送到XMPP",
- "风险监控": "宏观风险信号、跨市场背离检测",
- "自检/审计": "系统健康检查、监控采集、审计",
- "执行/修复": "自愈系统、门禁跟进、清理修复",
- "持仓复查": "持仓基本面复查和策略复盘",
- "信号消费": "消费小果情感分析和宏观风险信号",
- "系统服务": "系统维护(如DB真空整理)",
- "持仓监控": "特定持仓(300308/芯碁微装)盘中监控",
- "市场快照": "每10分钟采集全市场板块和指数快照",
- "宏观新闻": "采集宏观新闻和财经资讯",
- "价格监控": "每2分钟刷新持仓/自选实时价格→写入live_prices",
- "小果扫描": "小果独立扫描潜在机会",
- "资金流采集": "盘中采集板块资金流向",
- "宏观上下文刷新": "刷新大盘指数/市场情绪",
- "策略重评": "价格偏离买入区或策略过期时自动重评",
- "持仓自选新鲜度检查": "检查策略是否过期或价格严重偏离",
- "自选买入区提醒": "自选进入买入区时推送提醒",
- "策略评估": "每日/每周策略效果评估",
- "分支自成长": "策略分支探索和剪枝",
- "元自成长": "系统元层级自我进化",
- "MoFin盘前中监控": "上午盘中实时监控+推送",
- "MoFin午后监控": "下午盘中实时监控+推送",
- "cron报告推XMPP": "cron报告通过XMPP推送到手机",
- "开盘简报": "每日开盘前市场简报",
- "收盘简报": "每日收盘后市场简报",
- "市场精选推荐": "每日全市场潜力股精推",
- "宏观风险扫描": "从新闻中检测系统性风险",
- "宏观风险信号消费": "消费宏观风险信号并生成建议",
- "跨市场背离检测": "检测A股/港股/美股指数背离",
- "系统全局审计": "7维度系统全面审计",
- "全局cron健康监控": "监控所有cron的运行状态",
- "重评管道审计": "审计策略重评管道完整性",
- "健康监控数据采集": "采集健康数据供Dashboard展示",
- "自愈执行器": "每10分钟自动处理TODO列表",
- "策略质量门禁": "新策略必须通过9维验证才能写入",
- "自选自动清理": "开盘前清理过期自选数据",
- "建议对账": "每周对账校验建议准确性",
- "持仓基本面复查": "每周持仓基本面深度复查",
- "策略复盘": "每日策略执行复盘",
- "小果情感分析": "收盘后对持仓/自选做新闻情感分析",
- "宏观风险信号消费-盘中": "盘中消费宏观风险信号",
- "小果市场筛选": "全市场扫描值得关注的板块和个股",
- "芯碁微装": "芯碁微装午后价格监控",
- "300308": "300308午后紧盯+入场信号监控",
- "硬编码扫描": "扫描脚本中的硬编码参数",
- "系统体检": "开盘前系统全面体检",
- "盘中自检": "盘中高频自检",
- "记忆守卫": "每日记忆清理和优化",
- "数据治理": "每周数据清理和归档",
- "自选股自动重评": "周末自动重评自选股策略",
- "多周期缓存": "刷新MA5/MA20/MA60等技术指标缓存",
- "数据同步": "同步数据到Dashboard",
- "盘前热点扫描": "盘前扫描市场热点",
- "宏观新闻采集": "采集宏观新闻",
- "宏观新闻采集-周末": "周末宏观新闻采集",
- "state.db真空整理": "DB真空整理维护",
- "分支剪枝-每日": "修剪已失效的策略分支",
- "自选股自动重评-周末": "周末批量重评自选股策略",
- "系统健康检查-开盘前": "开盘前检查所有核心组件是否正常",
- "多周期缓存刷新-开盘前": "开盘前刷新技术指标缓存",
- "MoFin 系统常规体检-开盘前": "开盘前8:00全面系统体检",
- "开盘前钉对钉验证": "开盘前15项验证(脚本同步/DB完整性/资产公式)",
- "cron-推XMPP中继": "将cron输出通过XMPP中继推送",
- "小果信号消费-盘中": "盘中消费小果扫描信号",
- "硬编码扫描-每日": "扫描脚本中的硬编码参数",
- "盘中自检-高频": "每15分钟盘中自检",
- "数据治理-每周": "每周数据治理",
- "记忆守卫-每日": "每日记忆优化",
- "300308入场信号紧盯": "300308入场信号(13:00-14:00)",
- "300308午后紧盯": "300308午后监控(13:00-15:00含止损)",
- "多周期缓存刷新-盘中": "盘中刷新技术指标缓存",
- "知识萃取-盘后": "盘后从分析报告中萃取可复用知识",
- "区间维护": "每30分钟维护买入区",
- "知微洞察生成": "生成每日市场洞察(15:35)",
- "小果市场筛选-全市场": "小果筛选全市场关注板块",
- "数据同步-dashboard": "同步数据到Dashboard",
- "state.db真空整理-每周": "每周DB真空整理",
- "未分类": "未被规则匹配的cron自动归入此",
-}
-
-# ── 数据流详细描述 ──
-# 每张表说明:存什么 + 谁写入(为什么+写什么) + 谁读取(为什么+读什么) + 综合总结
-FLOW_DETAIL = {
- "signal_news": {
- "summary": "全系统信号/新闻的统一存储表,所有宏观分析、风险扫描、小果分析的输出汇聚地,也是下游消费脚本的输入源。7个写入方汇聚不同来源信号,5个读取方按需消费。",
- "writers": {
- "macro_context_collector": "写入宏观新闻原始数据(标题+摘要+分类),供后续风险扫描消费",
- "xiaoguo_news_processor": "写入小果LLM处理后的新闻情感分析结果",
- "macro_signal_consumer": "写入宏观风险信号判定结果(等级+来源+建议)",
- "divergence_detector": "写入跨市场背离检测信号(A股/港股/美股指数对)",
- "xiaoguo_signal_consumer": "写入小果扫描发现的个股/板块信号",
- "mofin_news": "写入外部财经常规新闻采集结果",
- "xiaoguo_scanner": "写入小果独立扫描的市场机会信号",
- },
- "readers": {
- "macro_signal_consumer": "读取原始宏观新闻和信号,判定风险等级并生成建议",
- "system_audit": "读取信号表行数/更新时间,审计数据管道是否畅通",
- "intraday_health_check": "读取最新信号,检查盘中是否有新的风险信号到达",
- "xiaoguo_signal_consumer": "读取小果相关信号,生成买入/卖出建议",
- "server": "读取信号数据供Web Dashboard展示",
- },
- },
- "holdings": {
- "summary": "当前持仓表,是系统最核心的数据表之一。import_holding_xls从券商文件导入持仓,mofin_db在价格刷新时更新市值。下游脚本读取持仓做策略分析和推送。",
- "writers": {
- "mofin_db": "写入price_monitor刷新后的持仓最新市值(通过write_holdings_batch)",
- "import_holding_xls": "从券商holding.xls导入最新持仓数量/成本/市值",
- },
- "readers": {
- "stale_push_wlin": "读取持仓列表+最新价格,检查是否进入买入区/触发止损",
- "mofin_db": "内部读取(get_price_from_db等函数)",
- "system_audit": "读取持仓总数/品种分布,审计持仓完整性",
- "server": "读取持仓数据供Web Dashboard展示",
- "prepare_report_data": "读取持仓数据用于生成分析报告",
- "mo_data": "通过read_portfolio()读取持仓结构化数据",
- },
- },
- "portfolio_summary": {
- "summary": "组合汇总表(id=1单行),记录总资产=持股市值+可用资金+冻结资金。每笔导入或价格刷新后更新。",
- "writers": {
- "mofin_db": "价格监控刷新总市值后更新total_mv/total_assets",
- "import_holding_xls": "导入持仓后更新cash/frozen/total_assets",
- },
- "readers": {
- "import_holding_xls": "读取当前汇总信息,验证导入后是否正确",
- "mo_data": "通过read_portfolio()读取组合汇总",
- "price_monitor": "读取当前现金/市值,计算总资产变动",
- "prepare_report_data": "读取总资产/现金数据用于报告",
- "server": "读取汇总数据供Dashboard展示",
- },
- },
- "holding_strategies": {
- "summary": "策略数据表,记录每只持仓/自选股的策略配置(买入价/止损/止盈/目标价/分析维度等)。多写入方按各自职责更新不同字段。",
- "writers": {
- "data_governance": "归档过期策略、修复异常策略数据",
- "sync_decisions_to_db": "从JSON同步策略到DB",
- "mofin_db": "策略写入(内部函数)",
- "strategy_review": "策略复盘后更新执行结果和评级",
- },
- "readers": {
- "data_governance": "读取所有活跃策略,检查缺失和异常",
- "per_stock_reassess": "读取个股策略配置,判断是否需要重评",
- "mo_data": "通过read_decisions()读取策略数据",
- "stale_push_wlin": "读取买入区/止损/止盈配置,检查价格触发",
- },
- },
- "live_prices": {
- "summary": "实时价格缓存表,price_monitor每2分钟写入全量持仓/自选价格。所有脚本必须通过mo_data.get_price()读取——先读此表,无数据才调API。单一写入、多方读取。",
- "writers": {
- "mofin_db": "price_monitor调用write_live_prices写入最新价格",
- "mo_data": "get_price()兜底时从API拉取价格后写回此表",
- },
- "readers": {
- "mo_data": "get_price()/get_prices_batch()优先从此表读取价格",
- "mofin_db": "内部读取(get_price_from_db)",
- "system_audit": "读取价格更新时间和数据量",
- "verify_reassess_pipeline": "验证重评管道是否有最新价格",
- },
- },
- "price_events": {
- "summary": "价格触发事件表,价格进入/离开买入区或触发止损止盈时记录事件。用于审计和重评触发。",
- "writers": {
- "mofin_db": "price_monitor检测到价格区间变化时写入事件记录",
- },
- "readers": {
- "mofin_db": "查询历史事件判断是否触发重评",
- },
- },
- "cash_log": {
- "summary": "资金流水表,每次资金变动(入金/出金/冻结/解冻)记录一条日志。审计用。",
- "writers": {
- "mofin_db": "通过write_cash_log记录资金变动",
- "mo_data": "write_cash_log函数入口",
- },
- "readers": {
- "prepare_report_data": "读取现金变动历史用于报告",
- "mofin_db": "内部查询最近流水",
- },
- },
- "market_snapshots": {
- "summary": "市场快照表,market_watch每10分钟采集全市场大盘指数+板块涨跌+上涨下跌家数。下游用于判断市场情绪。",
- "writers": {
- "mofin_db": "market_watch采集后写入快照数据",
- },
- "readers": {
- "market_screener": "读取最新板块快照,判断热点板块",
- "prepare_report_data": "读取市场情绪数据用于报告",
- "mofin_db": "内部查询最新快照",
- "system_audit": "审计数据新鲜度",
- },
- },
- "sector_snapshots": {
- "summary": "板块快照表,market_watch按板块写入涨跌/领涨股/资金流向。market_screener据此判断行业热点。",
- "writers": {
- "mofin_db": "market_watch采集后写入各板块数据",
- },
- "readers": {
- "market_screener": "读取板块涨跌排名,筛选热点行业",
- "strategy_lifecycle": "读取板块数据用于策略生命周期管理",
- "mofin_db": "内部查询",
- "trend_detector": "读取板块趋势数据用于趋势检测",
- },
- },
- "sector_signals": {
- "summary": "板块信号表,多源汇聚的板块级别信号(新闻情感+趋势+资金流向)。用于判断行业轮动。",
- "writers": {
- "mofin_news": "写入新闻分析得出的板块信号",
- "xiaoguo_news_processor": "写入小果LLM分析的板块情感信号",
- "trend_detector": "写入技术面趋势检测到的板块信号",
- },
- "readers": {
- "server": "读取供Dashboard展示",
- "mofin_news": "读取已有信号做增量更新",
- "xiaoguo_news_processor": "读取已有信号避免重复写入",
- "trend_detector": "读取信号辅助趋势判定",
- },
- },
- "macro_context_log": {
- "summary": "宏观上下文日志,refresh_macro_context每30分钟采集大盘指数/市场情绪/资金面数据。下游多个脚本按需读取最新宏观状态。",
- "writers": {
- "refresh_macro_context": "每30分钟采集上证/深证/创业板/恒指等指数+情绪指标",
- },
- "readers": {
- "stale_push_wlin": "读取大盘情绪用于策略推送的宏观背景",
- "divergence_detector": "读取多市场指数数据做背离检测",
- "system_audit": "审计数据采集是否正常",
- "xiaoguo_signal_consumer": "读取宏观情绪辅助信号判定",
- },
- },
- "macro_raw_news": {
- "summary": "宏观新闻原始数据表,macro_context_collector采集的未经处理的财经新闻。供后续清洗和分析。",
- "writers": {
- "macro_context_collector": "从财经网站采集原始新闻标题+URL+摘要",
- },
- "readers": {
- "macro_context_collector": "读取最近新闻hash避免重复采集",
- "system_audit": "审计新闻采集量",
- },
- },
- "accuracy_stats": {
- "summary": "策略准确率统计表,strategy_review复盘后写入各策略的正确/错误/待定计数。",
- "writers": {
- "strategy_review": "策略复盘后更新准确率统计",
- },
- "readers": {
- "mofin_db": "读取统计结果用于报告",
- },
- },
- "advice_timeline": {
- "summary": "建议时间线表,记录每条推送建议的时间/内容/状态。用于审计和对账。",
- "writers": {
- "advice_reconciliation": "每周对账时写入对账结果",
- },
- "readers": {
- "advice_reconciliation": "读取历史建议做对账",
- "mofin_db": "内部查询",
- },
- },
- "candidate_score_history": {
- "summary": "候选股评分历史表,记录每次全市场筛选时对候选股的评分。用于评分变化追踪。",
- "writers": {
- "mofin_db": "market_screener筛选结果写入评分记录",
- },
- "readers": {
- "mofin_db": "查询评分历史供展示",
- },
- },
- "candidates": {
- "summary": "候选股池表,market_screener筛选出的值得关注的个股。包含评分/买入区/止损/目标价。",
- "writers": {
- "mofin_db": "market_screener写入候选股",
- "market_screener": "直接写入候选股列表",
- },
- "readers": {
- "mofin_db": "读取候选股数据供展示和后续处理",
- },
- },
- "capital_flow_cache": {
- "summary": "资金流向缓存表,capital_flow_collector采集的板块资金流入流出数据。",
- "writers": {
- "mofin_db": "写入板块资金流向数据",
- },
- "readers": {
- "mofin_db": "读取缓存数据",
- },
- },
- "health_check_log": {
- "summary": "健康检查日志表,morning_health_check每次运行记录检查结果。用于追踪系统健康历史。",
- "writers": {
- "morning_health_check": "每日开盘前体检后写入检查结果",
- },
- "readers": {
- "morning_health_check": "读取历史检查结果比较变化",
- },
- },
- "mtf_cache": {
- "summary": "多周期技术指标缓存,refresh_mtf_cache计算MA5/MA20/MA60/支撑阻力位等。下游技术分析脚本从缓存读取避免重复计算。",
- "writers": {
- "multi_timeframe": "计算并写入多周期MA/支撑阻力位",
- "mofin_db": "内部写入函数",
- },
- "readers": {
- "multi_timeframe": "读取已有缓存判断是否需要刷新",
- "technical_analysis": "读取MA/支撑阻力位用于技术分析",
- "mofin_db": "内部读取",
- },
- },
- "stock_fundamentals": {
- "summary": "基本面数据表,存储PE/PB/ROE/市值等财务指标。",
- "writers": {
- "mofin_db": "基本面数据采集后写入",
- },
- "readers": {
- "strategy_lifecycle": "读取基本面数据用于策略评估",
- },
- },
- "stock_sectors": {
- "summary": "股票-板块映射表,记录每只股票所属行业板块。多脚本用于行业分类和板块归因。",
- "writers": {
- "mofin_db": "股票行业分类数据写入",
- },
- "readers": {
- "xiaoguo_news_processor": "按行业分类新闻",
- "mofin_news": "按行业归类新闻",
- "mofin_db": "内部查询",
- "strategy_lifecycle": "读取行业信息用于策略决策",
- },
- },
- "stocks": {
- "summary": "全量股票代码表,所有A股/港股基础信息。供各脚本按code查询股票名称/市场。",
- "writers": {
- "mofin_db": "初始化时导入全量股票代码",
- },
- "readers": {
- "mofin_news": "按股票代码查找新闻",
- "xiaoguo_news_processor": "按股票代码过滤新闻",
- "mofin_db": "内部查询",
- "trend_detector": "按股票代码获取数据",
- },
- },
- "strategy_evaluations": {
- "summary": "策略评估结果表,策略评估脚本每次运行记录评估得分/等级/评语。",
- "writers": {
- "mofin_collect": "策略评估前采集数据并写入评估结果",
- },
- "readers": {
- "verify_reassess_pipeline": "读取评估结果验证管道完整性",
- "mofin_db": "内部查询",
- "system_audit": "审计评估是否按时执行",
- },
- },
- "strategy_feedback": {
- "summary": "策略反馈表,记录用户对建议的反馈(采纳/忽略/修改)。用于策略自学习。",
- "writers": {
- "mofin_db": "写入反馈数据",
- "server": "通过Web提交反馈后写入",
- },
- "readers": {
- "mofin_db": "读取反馈用于分析和展示",
- },
- },
- "todos": {
- "summary": "待办事项表,各脚本发现异常时写入TODO,self_todo_executor每10分钟执行修复。异常发现→自动修复的闭环。",
- "writers": {
- "morning_health_check": "体检发现异常写入TODO",
- "intraday_health_check": "盘中自检发现异常写入TODO",
- "strategy-staleness-check": "策略过期检测写入TODO",
- "self_todo_executor": "执行完成后更新TODO状态",
- "preflight_verify": "开盘前验证失败写入TODO",
- },
- "readers": {
- "morning_health_check": "读取待处理的TODO",
- "self_todo_executor": "读取待处理的TODO并执行fix_action",
- "strategy-staleness-check": "读取TODO避免重复写入",
- "intraday_health_check": "读取TODO检查自愈进度",
- },
- },
- "watchlist_stocks": {
- "summary": "自选股表,系统自动维护的观察列表。与持仓表分离,用于跟踪潜在买入机会。",
- "writers": {
- "per_stock_reassess": "策略重评时更新自选状态",
- "mofin_db": "内部写入函数",
- },
- "readers": {
- "per_stock_reassess": "读取自选列表做重评",
- "stock_quote": "读取自选代码拉取行情",
- "mo_alphasift_bridge": "读取自选供Alpha分析",
- "mo_data": "通过read_watchlist()读取自选数据",
- },
- },
- "xiaoguo_scan_tracker": {
- "summary": "小果扫描追踪表,记录每次小果扫描的状态/耗时/结果数量。用于监控小果服务健康。",
- "writers": {
- "xiaoguo_scanner": "每次扫描完成后写入状态和统计",
- },
- "readers": {
- "server": "读取扫描状态供Dashboard展示",
- "xiaoguo_scanner": "读取上次扫描时间判断是否需要全量扫描",
- },
- },
- "state_meta": {
- "summary": "状态元数据表,记录各服务的状态追踪信息(如扫描偏移量/最新处理ID)。",
- "writers": {
- "xiaoguo_scanner": "写入扫描进度偏移量",
- },
- "readers": {
- "xiaoguo_scanner": "读取上次处理位置继续增量处理",
- },
- },
-}
-
-
-def build_feature_tree(cron_jobs, db_stats):
- # 硬编码分类规则:标签→匹配关键词
- rules = {
- "市场快照": ["市场数据采集"],
- "宏观新闻": ["宏观采集"],
- "价格监控": ["价格监控"],
- "小果扫描": ["小果独立扫描"],
- "资金流采集": ["资金流"],
- "宏观上下文刷新": ["宏观上下文刷新"],
- "策略重评": ["策略重评"],
- "持仓自选新鲜度检查": ["策略时效性检查"],
- "自选买入区提醒": ["自选买入区提醒"],
- "策略评估": ["策略评估"],
- "分支自成长": ["分支自成长"],
- "元自成长": ["元自成长"],
- "MoFin盘前中监控": ["MoFin盘前中监控"],
- "MoFin午后监控": ["MoFin午后监控"],
- "cron报告推XMPP": ["cron报告推XMPP"],
- "开盘简报": ["开盘简报"],
- "收盘简报": ["收盘简报"],
- "市场精选推荐": ["市场精选推荐"],
- "小果情感分析": ["小果情感分析"],
- "系统全局审计": ["系统全局审计"],
- "全局cron健康监控": ["全局cron健康监控"],
- "重评管道审计": ["重评管道审计"],
- "健康监控数据采集": ["健康监控数据采集"],
- "持仓基本面复查": ["分析师-持仓复查"],
- "策略复盘": ["策略复盘"],
- "宏观风险扫描": ["宏观风险扫描"],
- "宏观风险信号消费": ["宏观风险信号消费"],
- "跨市场背离检测": ["跨市场背离检测"],
- "自愈执行器": ["自愈执行器"],
- "策略质量门禁": ["策略质量门禁"],
- "自选自动清理": ["自选自动清理"],
- "建议对账": ["建议对账"],
- "宏观新闻采集": ["宏观新闻采集"],
- "数据治理": ["数据治理"],
- "盘前热点扫描": ["盘前热点扫描"],
- "数据同步": ["数据同步"],
- "小果市场筛选": ["小果市场筛选"],
- "芯碁微装": ["芯碁微装"],
- "宏观新闻采集-周末": ["宏观新闻采集-周末"],
- "硬编码扫描": ["硬编码扫描"],
- "系统体检": ["系统体检"],
- "盘中自检": ["盘中自检"],
- "记忆守卫": ["记忆守卫"],
- "数据治理": ["数据治理"],
- "自选股自动重评": ["自选股自动重评"],
- "state.db真空整理": ["真空整理"],
- "300308": ["300308"],
- "多周期缓存": ["多周期缓存"],
- "元自成长": ["元自成长"],
- }
- # 自动归类:未被任何规则匹配的cron按名称关键词归入类别
- # 关键词必须够精确,避免误归类
- AUTO_CATEGORIES = [
- ("数据采集", ["市场数据", "宏观采集", "新闻采集", "价格监控", "资金流采集", "小果独立扫描", "上下文刷新"]),
- ("策略分析", ["策略评估", "策略时效性", "重评", "买入区提醒", "自成长", "策略复盘", "分支"]),
- ("推荐推送", ["简报", "推送", "推荐", "XMPP", "开盘", "收盘"]),
- ("风险监控", ["宏观风险", "背离检测", "信号消费"]),
- ("自检/审计", ["系统全局审计", "健康监控", "管道审计", "系统体检", "盘中自检", "记忆守卫", "硬编码扫描", "治理"]),
- ("执行/修复", ["自愈执行", "门禁", "清理", "对账", "TODO"]),
- ("持仓监控", ["300308", "芯碁微装", "多周期缓存", "自选股自动重评"]),
- ("系统服务", ["真空整理"]),
- ]
-
- matched_names = set() # 记录已匹配的cron name
-
- def attach_pipes(node, parent_cat=None):
- nonlocal matched_names
- label = node.get("label", "")
- # 附加描述(自动带脚本名的节点去掉括号内容匹配)
- desc_key = label.split(" (")[0] if " (" in label else label
- if desc_key in NODE_DESC:
- node["desc"] = NODE_DESC[desc_key]
- keywords = rules.get(label)
- pipes = []
- if keywords:
- matched = match_cron(cron_jobs, keywords)
- for j in matched:
- n = j.get("name", "")
- matched_names.add(n)
- pipes = [{
- "name": j.get("name", ""),
- "script": j.get("script", ""),
- "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))),
- "status": j.get("last_status", "unknown"),
- "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "",
- "type": "no_agent" if j.get("no_agent") else "LLM",
- "profile": j.get("profile", "?"),
- } for j in matched]
- if pipes:
- node["pipes"] = pipes
- if node.get("children"):
- for c in node["children"]:
- attach_pipes(c, parent_cat or label)
-
- def make_cron_node(j):
- name = j.get("name", "?")
- desc_key = name.split(" (")[0] if " (" in name else name
- return {
- "label": f"{name} ({j.get('script','LLM')})",
- "desc": NODE_DESC.get(desc_key, ""),
- "status": j.get("last_status", "unknown"),
- "pipes": [{
- "name": j.get("name", ""),
- "script": j.get("script", ""),
- "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))),
- "status": j.get("last_status", "unknown"),
- "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "",
- "type": "no_agent" if j.get("no_agent") else "LLM",
- "profile": j.get("profile", "?"),
- }]
- }
-
- tree = {
- "label": "MoFin 系统",
- "status": "ok",
- "children": [
- {"label": "数据采集", "status": "ok", "children": [
- {"label": "市场快照", "status": "ok"},
- {"label": "宏观新闻", "status": "ok"},
- {"label": "价格监控", "status": "ok"},
- {"label": "小果扫描", "status": "ok"},
- {"label": "资金流采集", "status": "ok"},
- {"label": "宏观上下文刷新", "status": "ok"},
- ]},
- {"label": "策略分析", "status": "ok", "children": [
- {"label": "策略重评", "status": "ok"},
- {"label": "持仓自选新鲜度检查", "status": "ok"},
- {"label": "自选买入区提醒", "status": "ok"},
- {"label": "策略评估", "status": "ok"},
- {"label": "分支自成长", "status": "ok"},
- {"label": "元自成长", "status": "ok"},
- ]},
- {"label": "推荐推送", "status": "ok", "children": [
- {"label": "MoFin盘前中监控", "status": "ok"},
- {"label": "MoFin午后监控", "status": "ok"},
- {"label": "cron报告推XMPP", "status": "ok"},
- {"label": "开盘简报", "status": "ok"},
- {"label": "收盘简报", "status": "ok"},
- {"label": "市场精选推荐", "status": "ok"},
- ]},
- {"label": "风险监控", "status": "ok", "children": [
- {"label": "宏观风险扫描", "status": "ok"},
- {"label": "宏观风险信号消费", "status": "ok"},
- {"label": "跨市场背离检测", "status": "ok"},
- ]},
- {"label": "自检/审计", "status": "ok", "children": [
- {"label": "系统全局审计", "status": "ok"},
- {"label": "全局cron健康监控", "status": "ok"},
- {"label": "重评管道审计", "status": "ok"},
- {"label": "健康监控数据采集", "status": "ok"},
- ]},
- {"label": "执行/修复", "status": "ok", "children": [
- {"label": "自愈执行器", "status": "ok"},
- {"label": "策略质量门禁", "status": "ok"},
- {"label": "自选自动清理", "status": "ok"},
- {"label": "建议对账", "status": "ok"},
- ]},
- {"label": "持仓复查", "status": "ok", "children": [
- {"label": "持仓基本面复查", "status": "ok"},
- {"label": "策略复盘", "status": "ok"},
- ]},
- {"label": "信号消费", "status": "ok", "children": [
- {"label": "小果情感分析", "status": "ok"},
- {"label": "宏观风险信号消费-盘中", "status": "ok"},
- ]},
- ],
- }
-
- attach_pipes(tree)
-
- # 收集所有未被任何规则匹配的cron,按名称自动归入类别
- unmatched = [j for j in cron_jobs if j.get("name", "") not in matched_names]
-
- # 按自动归类分组
- cat_map = {}
- for j in unmatched:
- name = j.get("name", "")
- assigned = False
- for cat_name, keywords in AUTO_CATEGORIES:
- if any(kw in name for kw in keywords):
- cat_map.setdefault(cat_name, []).append(j)
- assigned = True
- break
- if not assigned:
- cat_map.setdefault("未分类", []).append(j)
-
- # 将自动归类的cron追加到已有分类或创建新分类
- for cat_name, jobs in sorted(cat_map.items()):
- # 如果该分类已存在于树中,追加到其children
- found = None
- for child in tree["children"]:
- if child["label"] == cat_name:
- found = child
- break
- if found:
- existing_labels = {c["label"] for c in found.get("children", [])}
- for j in jobs:
- lbl = j.get("name", "?")
- if lbl not in existing_labels:
- found["children"].append(make_cron_node(j))
- existing_labels.add(lbl)
- else:
- tree["children"].append({
- "label": cat_name,
- "status": "ok",
- "children": [make_cron_node(j) for j in jobs],
- })
-
- return tree
-
-def build_report():
- cron_jobs = load_cron_jobs()
- db_stats = get_db_stats()
- flows = scan_data_flows()
- script_health = check_scripts()
-
- # ── 功能树(只显示知微的cron)──
- zhiwei_crons = [j for j in cron_jobs if j.get("profile") == "position-analyst" or j.get("name") in [
- "cron-推XMPP中继", "数据同步-dashboard", "记忆守卫-每日", "市场数据采集"
- ]]
- feature_tree = build_feature_tree(zhiwei_crons, db_stats)
- # 递归计算节点状态
- def calc_status(node):
- if "children" in node:
- for c in node["children"]:
- calc_status(c)
- statuses = [c["status"] for c in node["children"]]
- if "fail" in statuses: node["status"] = "fail"
- elif "warn" in statuses: node["status"] = "warn"
- else: node["status"] = "ok"
- calc_status(feature_tree)
-
- # ── Tab 2: 数据实体表 ──
- entities = []
- for tname, cnt in sorted(db_stats.items()):
- readers = flows["db_read"].get(tname, [])
- writers = flows["db_write"].get(tname, [])
- # 扫描器漏检的手动补录写入方
- _manual_writers = {
- "candidates": ["mofin_db", "market_screener"],
- "candidate_score_history": ["mofin_db"],
- "strategy_feedback": ["mofin_db", "server"],
- "stock_daily": ["mofin_db"],
- "stock_weekly": ["mofin_db"],
- "stock_monthly": ["mofin_db"],
- }
- _manual_readers = {
- "stock_weekly": ["multi_timeframe"],
- "stock_monthly": ["multi_timeframe"],
- "watchlist_log": ["watchlist_auto_exit", "mofin_db"],
- }
- if not writers and tname in _manual_writers:
- writers = _manual_writers[tname]
- if not readers and tname in _manual_readers:
- readers = _manual_readers[tname]
-
- # 数据流详细描述
- flow_detail = FLOW_DETAIL.get(tname, {})
-
- has_input = len(writers) > 0
- has_output = len(readers) > 0
- # 排除系统表
- is_system = tname.startswith("sqlite_") or tname.startswith("_")
- if is_system:
- continue
- # 数据流状态:healthy / write_only / read_only / orphan
- if has_input and has_output:
- flow_status = "healthy"
- elif has_input and not has_output:
- flow_status = "write_only"
- elif not has_input and has_output:
- flow_status = "read_only"
- else:
- flow_status = "orphan"
- entities.append({
- "name": tname,
- "desc": TABLES_DESC.get(tname, ""),
- "rows": cnt,
- "readers": readers[:10],
- "writers": writers[:10],
- "has_input": has_input,
- "has_output": has_output,
- "orphan": flow_status in ("orphan", "read_only", "write_only"),
- "flow_status": flow_status,
- "warn": flow_status != "healthy",
- "flow_detail": flow_detail,
- })
-
- # JSON文件
- # 已迁移到DB的旧JSON文件:不再报"无读取方"假警报,真实健康信号看DB表新鲜度
- MIGRATED_TO_DB = {
- "multi_tf_cache.json": "mtf_cache",
- "macro_context.json": "macro_context_log",
- "market.json": "market_snapshots",
- "live_prices.json": "live_prices",
- "price_history.json": "price_events",
- "macro_risk_state.json": "macro_context_log",
- }
- json_entities = []
- for jf in sorted(WEB_DATA.glob("*.json")):
- if jf.name == "stocks": continue
- if jf.stem.startswith("temp_"): continue
- readers = flows["json_read"].get(jf.name, [])
- size = jf.stat().st_size / 1024
- migrated = MIGRATED_TO_DB.get(jf.name)
- desc = JSON_DESC.get(jf.name, "")
- if migrated:
- desc = (desc + " " if desc else "") + f"(已迁移到DB表 {migrated},此为遗留文件)"
- json_entities.append({
- "name": jf.name,
- "desc": desc,
- "size_kb": round(size, 1),
- "readers": readers[:10],
- "writers": [], # 难以精确追踪
- "last_modified": datetime.fromtimestamp(jf.stat().st_mtime).strftime("%m-%d %H:%M"),
- "warn": (len(readers) == 0 and jf.name not in ("portfolio.json", "market.json")
- and not migrated),
- "migrated_to_db": migrated or None,
- })
-
- # ── DB表新鲜度:真实数据管道健康信号(替代对遗留JSON文件的mtime检查)──
- # 注意:活跃数据在 /home/hmo/MoFin/data/mofin.db(live_prices/mtf_cache 今日有写入),
- # 不用 get_conn()(它指向 web-dashboard 的库,那边部分表是旧的)
- db_freshness = []
- FRESHNESS_TABLES = [
- ("mtf_cache", "updated_at", "多周期均线缓存"),
- ("macro_context_log", "created_at", "宏观上下文"),
- ("market_snapshots", "created_at", "市场快照"),
- ("live_prices", "updated_at", "实时价格"),
- ("price_events", "created_at", "价格事件"),
- ]
- try:
- _fc = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
- for tname, tcol, label in FRESHNESS_TABLES:
- try:
- row = _fc.execute(
- f"SELECT MAX({tcol}) FROM {tname}").fetchone()
- if row and row[0]:
- last_dt = datetime.fromisoformat(str(row[0]).replace("Z", ""))
- age_h = (now - last_dt).total_seconds() / 3600
- db_freshness.append({
- "table": tname, "label": label,
- "last_record": last_dt.strftime("%m-%d %H:%M"),
- "age_hours": round(age_h, 1),
- "warn": age_h > 24,
- })
- else:
- db_freshness.append({"table": tname, "label": label,
- "last_record": None, "age_hours": -1, "warn": True})
- except Exception:
- pass # 表不存在或列名不同,跳过
- _fc.close()
- except Exception:
- pass
-
- # ── Tab 3: 流程/cron映射 ──
- pipelines = []
- for j in sorted(cron_jobs, key=lambda x: x.get("name","")):
- if not j.get("enabled", True):
- continue
- name = j.get("name", "?")
- script = j.get("script", "")
- status = j.get("last_status", "unknown")
- last_run = str(j.get("last_run_at", ""))[:19]
- schedule = j.get("schedule", {}).get("display", str(j.get("schedule","")))
- no_agent = j.get("no_agent", False)
- pipelines.append({
- "name": name,
- "type": "no_agent" if no_agent else "LLM",
- "script": script,
- "schedule": schedule,
- "status": status,
- "last_run": last_run,
- "profile": j.get("profile", "?"),
- })
-
- # ── 写JSON ──
- report = {
- "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
- "feature_tree": feature_tree,
- "entities": entities,
- "json_files": json_entities,
- "pipelines": pipelines,
- "db_freshness": db_freshness,
- }
- out_path = WEB_DATA / "mofin_health.json"
- with open(out_path, "w") as f:
- json.dump(report, f, ensure_ascii=False, indent=2)
- # 也写到static目录供dashboard直接serve
- with open(STATIC_DIR / "mofin_health.json", "w") as f:
- json.dump(report, f, ensure_ascii=False, indent=2)
- print(f"[SILENT] mofin_health.json written ({len(entities)} entities, {len(pipelines)} pipelines)")
-
-if __name__ == "__main__":
- build_report()
+#!/usr/bin/env python3
+"""mofin_health.py — MoFin 健康监控数据采集
+
+输出JSON供dashboard展示,三个view:
+ tab1: 功能树(逐级展开,每节点绿/黄/红)
+ tab2: 数据实体表(输入/输出流分析,孤立表报警)
+ tab3: 流程/cron映射(状态正常/异常)
+"""
+import json, os, sys, re
+import sqlite3
+from pathlib import Path
+from datetime import datetime, timezone
+from mofin_db import get_conn
+
+DATA_DIR = Path("/home/hmo/MoFin/data")
+WEB_DATA = Path("/home/hmo/web-dashboard/data")
+STATIC_DIR = Path("/home/hmo/web-dashboard/static")
+PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts")
+CRON_FILES = [
+ "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json",
+ "/home/hmo/.hermes/cron/jobs.json",
+]
+
+# 数据实体作用说明
+TABLES_DESC = {
+ "holdings": "当前持仓(权威源)",
+ "holding_strategies": "每只股票的完整策略参数",
+ "portfolio_summary": "总资产/现金/仓位汇总",
+ "portfolio_state": "组合状态快照(只读派生)",
+ "strategy_evaluations": "策略重评历史记录",
+ "strategy_feedback": "策略效果反馈",
+ "watchlist_stocks": "自选股列表",
+ "candidates": "潜力股候选池(小果扫描产出)",
+ "live_prices": "所有持仓+自选最新实时价",
+ "price_events": "价格区间突破事件日志",
+ "market_snapshots": "大盘指数快照(每10分)",
+ "sector_snapshots": "行业板块数据",
+ "sector_signals": "行业信号(趋势检测产出)",
+ "signal_news": "信号相关新闻",
+ "macro_raw_news": "宏观新闻原始数据",
+ "macro_context_log": "宏观上下文(大盘偏向/指数)",
+ "stocks": "全量股票代码",
+ "stock_daily": "日线行情",
+ "stock_weekly": "周线行情",
+ "stock_monthly": "月线行情",
+ "stock_fundamentals": "基本面数据(PE/PB)",
+ "stock_sectors": "股票行业映射",
+ "capital_flow_cache": "资金流缓存",
+ "xiaoguo_scan_tracker": "小果扫描跟踪",
+ "advice_timeline": "建议执行时间线",
+ "accuracy_stats": "建议准确率统计",
+ "todos": "自愈任务队列",
+ "health_check_log": "健康检查日志",
+ "cash_log": "资金变动记录",
+ "mtf_cache": "多周期均线缓存",
+ "state_meta": "系统状态元数据",
+}
+
+JSON_DESC = {
+ "decisions.json": "策略决策(DB→JSON同步,兼容层)",
+ "portfolio.json": "持仓汇总(兼容层)",
+ "market.json": "市场概况数据",
+ "xiaoguo_insights.json": "小果分析洞察",
+ "candidate_pool.json": "潜力股候选池完整数据",
+ "zone_breach.json": "价格区间突破状态",
+ "strategy_staleness_report.json": "策略过期报告",
+ "alerts.json": "告警列表",
+ "macro_risk_state.json": "宏观风险状态(采集器写入)",
+ "capital_flow_cache.json": "资金流缓存",
+ "multi_tf_cache.json": "多周期均线缓存",
+ "macro_context.json": "宏观上下文JSON(旧兼容层)",
+ "system_inventory.json": "全量系统清单",
+ "mofin_health.json": "健康监控数据",
+}
+
+now = datetime.now()
+
+def load_cron_jobs():
+ jobs = []
+ seen = set()
+ for jf in CRON_FILES:
+ profile_tag = "position-analyst" if "position-analyst" in str(jf) else "default"
+ try:
+ for j in json.load(open(jf)).get("jobs", []):
+ jid = j.get("id", "")
+ if jid in seen: continue
+ seen.add(jid)
+ j["profile"] = profile_tag
+ jobs.append(j)
+ except: pass
+ return jobs
+
+def get_db_stats():
+ conn = get_conn()
+ tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall()
+ stats = {}
+ for (tname,) in tables:
+ cnt = conn.execute(f"SELECT COUNT(*) FROM \"{tname}\"").fetchone()[0]
+ stats[tname] = cnt
+ conn.close()
+ return stats
+
+def scan_data_flows():
+ """对每个脚本,扫描它读/写了哪些DB表和JSON文件"""
+ flows = {"db_read": {}, "db_write": {}, "json_read": {}, "json_write": {}}
+ for py in sorted(PROFILE_SCRIPTS.glob("*.py")):
+ name = py.stem
+ content = py.read_text(encoding="utf-8", errors="ignore")
+ # DB reads: SELECT FROM
+ reads = set(re.findall(r'FROM\s+(\w+)', content, re.I))
+ reads |= set(re.findall(r'join\s+(\w+)', content, re.I))
+ # DB writes: INSERT INTO / UPDATE / DELETE FROM
+ writes = set(re.findall(r'INSERT\s+(?:OR\s+\w+\s+)?INTO\s+(\w+)', content, re.I))
+ writes |= set(re.findall(r'UPDATE\s+(\w+)', content, re.I))
+ writes |= set(re.findall(r'DELETE\s+FROM\s+(\w+)', content, re.I))
+ # JSON reads: json.load/open
+ json_r = set(re.findall(r'(?:json\.load|open)\s*\(\s*["\']([^"\']+\.json)', content))
+ json_w = set(re.findall(r'(?:json\.dump|json\.dumps)\s*\(', content))
+ for t in reads: flows["db_read"].setdefault(t, set()).add(name)
+ for t in writes: flows["db_write"].setdefault(t, set()).add(name)
+ for f in json_r:
+ fname = os.path.basename(f)
+ flows["json_read"].setdefault(fname, set()).add(name)
+ if json_w:
+ flows["json_write"].setdefault(name, set()).add(name)
+ return {k: {kk: list(vv) for kk, vv in v.items()} for k, v in flows.items()}
+
+def check_scripts():
+ """检查每个脚本是否有语法错误或明显问题"""
+ issues = {}
+ for py in sorted(PROFILE_SCRIPTS.glob("*.py")):
+ r = os.system(f"python3 -m py_compile {py} 2>/dev/null")
+ issues[py.stem] = "ok" if r == 0 else "syntax_error"
+ return issues
+
+
+def match_cron(cron_jobs, name_keywords):
+ """匹配cron任务列表,返回匹配的cron信息列表(空格归一化后匹配)"""
+ matches = []
+ for j in cron_jobs:
+ jname = j.get("name", "").replace(" ", "").replace("\u00a0", "") # 去空格再比
+ if isinstance(name_keywords, str):
+ if name_keywords.replace(" ", "") in jname:
+ matches.append(j)
+ elif isinstance(name_keywords, (list, tuple)):
+ clean_kws = [k.replace(" ", "").replace("\u00a0", "") for k in name_keywords]
+ if any(kw in jname for kw in clean_kws):
+ matches.append(j)
+ elif callable(name_keywords):
+ if name_keywords(j):
+ matches.append(j)
+ # 去重(相同name只保留一条)
+ seen = set()
+ deduped = []
+ for j in matches:
+ n = j.get("name", "")
+ if n not in seen:
+ seen.add(n)
+ deduped.append(j)
+ return deduped
+
+
+# ── 功能树描述 ──
+NODE_DESC = {
+ "数据采集": "从腾讯/东财/小果采集原始行情、新闻、资金流数据",
+ "策略分析": "策略评估、新鲜度检查、重评和成长分析",
+ "推荐推送": "生成简报、推荐并推送到XMPP",
+ "风险监控": "宏观风险信号、跨市场背离检测",
+ "自检/审计": "系统健康检查、监控采集、审计",
+ "执行/修复": "自愈系统、门禁跟进、清理修复",
+ "持仓复查": "持仓基本面复查和策略复盘",
+ "信号消费": "消费小果情感分析和宏观风险信号",
+ "系统服务": "系统维护(如DB真空整理)",
+ "持仓监控": "特定持仓(300308/芯碁微装)盘中监控",
+ "市场快照": "每10分钟采集全市场板块和指数快照",
+ "宏观新闻": "采集宏观新闻和财经资讯",
+ "价格监控": "每2分钟刷新持仓/自选实时价格→写入live_prices",
+ "小果扫描": "小果独立扫描潜在机会",
+ "资金流采集": "盘中采集板块资金流向",
+ "宏观上下文刷新": "刷新大盘指数/市场情绪",
+ "策略重评": "价格偏离买入区或策略过期时自动重评",
+ "持仓自选新鲜度检查": "检查策略是否过期或价格严重偏离",
+ "自选买入区提醒": "自选进入买入区时推送提醒",
+ "策略评估": "每日/每周策略效果评估",
+ "分支自成长": "策略分支探索和剪枝",
+ "元自成长": "系统元层级自我进化",
+ "MoFin盘前中监控": "上午盘中实时监控+推送",
+ "MoFin午后监控": "下午盘中实时监控+推送",
+ "cron报告推XMPP": "cron报告通过XMPP推送到手机",
+ "开盘简报": "每日开盘前市场简报",
+ "收盘简报": "每日收盘后市场简报",
+ "市场精选推荐": "每日全市场潜力股精推",
+ "宏观风险扫描": "从新闻中检测系统性风险",
+ "宏观风险信号消费": "消费宏观风险信号并生成建议",
+ "跨市场背离检测": "检测A股/港股/美股指数背离",
+ "系统全局审计": "7维度系统全面审计",
+ "全局cron健康监控": "监控所有cron的运行状态",
+ "重评管道审计": "审计策略重评管道完整性",
+ "健康监控数据采集": "采集健康数据供Dashboard展示",
+ "自愈执行器": "每10分钟自动处理TODO列表",
+ "策略质量门禁": "新策略必须通过9维验证才能写入",
+ "自选自动清理": "开盘前清理过期自选数据",
+ "建议对账": "每周对账校验建议准确性",
+ "持仓基本面复查": "每周持仓基本面深度复查",
+ "策略复盘": "每日策略执行复盘",
+ "小果情感分析": "收盘后对持仓/自选做新闻情感分析",
+ "宏观风险信号消费-盘中": "盘中消费宏观风险信号",
+ "小果市场筛选": "全市场扫描值得关注的板块和个股",
+ "芯碁微装": "芯碁微装午后价格监控",
+ "300308": "300308午后紧盯+入场信号监控",
+ "硬编码扫描": "扫描脚本中的硬编码参数",
+ "系统体检": "开盘前系统全面体检",
+ "盘中自检": "盘中高频自检",
+ "记忆守卫": "每日记忆清理和优化",
+ "数据治理": "每周数据清理和归档",
+ "自选股自动重评": "周末自动重评自选股策略",
+ "多周期缓存": "刷新MA5/MA20/MA60等技术指标缓存",
+ "数据同步": "同步数据到Dashboard",
+ "盘前热点扫描": "盘前扫描市场热点",
+ "宏观新闻采集": "采集宏观新闻",
+ "宏观新闻采集-周末": "周末宏观新闻采集",
+ "state.db真空整理": "DB真空整理维护",
+ "分支剪枝-每日": "修剪已失效的策略分支",
+ "自选股自动重评-周末": "周末批量重评自选股策略",
+ "系统健康检查-开盘前": "开盘前检查所有核心组件是否正常",
+ "多周期缓存刷新-开盘前": "开盘前刷新技术指标缓存",
+ "MoFin 系统常规体检-开盘前": "开盘前8:00全面系统体检",
+ "开盘前钉对钉验证": "开盘前15项验证(脚本同步/DB完整性/资产公式)",
+ "cron-推XMPP中继": "将cron输出通过XMPP中继推送",
+ "小果信号消费-盘中": "盘中消费小果扫描信号",
+ "硬编码扫描-每日": "扫描脚本中的硬编码参数",
+ "盘中自检-高频": "每15分钟盘中自检",
+ "数据治理-每周": "每周数据治理",
+ "记忆守卫-每日": "每日记忆优化",
+ "300308入场信号紧盯": "300308入场信号(13:00-14:00)",
+ "300308午后紧盯": "300308午后监控(13:00-15:00含止损)",
+ "多周期缓存刷新-盘中": "盘中刷新技术指标缓存",
+ "知识萃取-盘后": "盘后从分析报告中萃取可复用知识",
+ "区间维护": "每30分钟维护买入区",
+ "知微洞察生成": "生成每日市场洞察(15:35)",
+ "小果市场筛选-全市场": "小果筛选全市场关注板块",
+ "数据同步-dashboard": "同步数据到Dashboard",
+ "state.db真空整理-每周": "每周DB真空整理",
+ "未分类": "未被规则匹配的cron自动归入此",
+}
+
+# ── 数据流详细描述 ──
+# 每张表说明:存什么 + 谁写入(为什么+写什么) + 谁读取(为什么+读什么) + 综合总结
+FLOW_DETAIL = {
+ "signal_news": {
+ "summary": "全系统信号/新闻的统一存储表,所有宏观分析、风险扫描、小果分析的输出汇聚地,也是下游消费脚本的输入源。7个写入方汇聚不同来源信号,5个读取方按需消费。",
+ "writers": {
+ "macro_context_collector": "写入宏观新闻原始数据(标题+摘要+分类),供后续风险扫描消费",
+ "xiaoguo_news_processor": "写入小果LLM处理后的新闻情感分析结果",
+ "macro_signal_consumer": "写入宏观风险信号判定结果(等级+来源+建议)",
+ "divergence_detector": "写入跨市场背离检测信号(A股/港股/美股指数对)",
+ "xiaoguo_signal_consumer": "写入小果扫描发现的个股/板块信号",
+ "mofin_news": "写入外部财经常规新闻采集结果",
+ "xiaoguo_scanner": "写入小果独立扫描的市场机会信号",
+ },
+ "readers": {
+ "macro_signal_consumer": "读取原始宏观新闻和信号,判定风险等级并生成建议",
+ "system_audit": "读取信号表行数/更新时间,审计数据管道是否畅通",
+ "intraday_health_check": "读取最新信号,检查盘中是否有新的风险信号到达",
+ "xiaoguo_signal_consumer": "读取小果相关信号,生成买入/卖出建议",
+ "server": "读取信号数据供Web Dashboard展示",
+ },
+ },
+ "holdings": {
+ "summary": "当前持仓表,是系统最核心的数据表之一。import_holding_xls从券商文件导入持仓,mofin_db在价格刷新时更新市值。下游脚本读取持仓做策略分析和推送。",
+ "writers": {
+ "mofin_db": "写入price_monitor刷新后的持仓最新市值(通过write_holdings_batch)",
+ "import_holding_xls": "从券商holding.xls导入最新持仓数量/成本/市值",
+ },
+ "readers": {
+ "stale_push_wlin": "读取持仓列表+最新价格,检查是否进入买入区/触发止损",
+ "mofin_db": "内部读取(get_price_from_db等函数)",
+ "system_audit": "读取持仓总数/品种分布,审计持仓完整性",
+ "server": "读取持仓数据供Web Dashboard展示",
+ "prepare_report_data": "读取持仓数据用于生成分析报告",
+ "mo_data": "通过read_portfolio()读取持仓结构化数据",
+ },
+ },
+ "portfolio_summary": {
+ "summary": "组合汇总表(id=1单行),记录总资产=持股市值+可用资金+冻结资金。每笔导入或价格刷新后更新。",
+ "writers": {
+ "mofin_db": "价格监控刷新总市值后更新total_mv/total_assets",
+ "import_holding_xls": "导入持仓后更新cash/frozen/total_assets",
+ },
+ "readers": {
+ "import_holding_xls": "读取当前汇总信息,验证导入后是否正确",
+ "mo_data": "通过read_portfolio()读取组合汇总",
+ "price_monitor": "读取当前现金/市值,计算总资产变动",
+ "prepare_report_data": "读取总资产/现金数据用于报告",
+ "server": "读取汇总数据供Dashboard展示",
+ },
+ },
+ "holding_strategies": {
+ "summary": "策略数据表,记录每只持仓/自选股的策略配置(买入价/止损/止盈/目标价/分析维度等)。多写入方按各自职责更新不同字段。",
+ "writers": {
+ "data_governance": "归档过期策略、修复异常策略数据",
+ "sync_decisions_to_db": "从JSON同步策略到DB",
+ "mofin_db": "策略写入(内部函数)",
+ "strategy_review": "策略复盘后更新执行结果和评级",
+ },
+ "readers": {
+ "data_governance": "读取所有活跃策略,检查缺失和异常",
+ "per_stock_reassess": "读取个股策略配置,判断是否需要重评",
+ "mo_data": "通过read_decisions()读取策略数据",
+ "stale_push_wlin": "读取买入区/止损/止盈配置,检查价格触发",
+ },
+ },
+ "live_prices": {
+ "summary": "实时价格缓存表,price_monitor每2分钟写入全量持仓/自选价格。所有脚本必须通过mo_data.get_price()读取——先读此表,无数据才调API。单一写入、多方读取。",
+ "writers": {
+ "mofin_db": "price_monitor调用write_live_prices写入最新价格",
+ "mo_data": "get_price()兜底时从API拉取价格后写回此表",
+ },
+ "readers": {
+ "mo_data": "get_price()/get_prices_batch()优先从此表读取价格",
+ "mofin_db": "内部读取(get_price_from_db)",
+ "system_audit": "读取价格更新时间和数据量",
+ "verify_reassess_pipeline": "验证重评管道是否有最新价格",
+ },
+ },
+ "price_events": {
+ "summary": "价格触发事件表,价格进入/离开买入区或触发止损止盈时记录事件。用于审计和重评触发。",
+ "writers": {
+ "mofin_db": "price_monitor检测到价格区间变化时写入事件记录",
+ },
+ "readers": {
+ "mofin_db": "查询历史事件判断是否触发重评",
+ },
+ },
+ "cash_log": {
+ "summary": "资金流水表,每次资金变动(入金/出金/冻结/解冻)记录一条日志。审计用。",
+ "writers": {
+ "mofin_db": "通过write_cash_log记录资金变动",
+ "mo_data": "write_cash_log函数入口",
+ },
+ "readers": {
+ "prepare_report_data": "读取现金变动历史用于报告",
+ "mofin_db": "内部查询最近流水",
+ },
+ },
+ "market_snapshots": {
+ "summary": "市场快照表,market_watch每10分钟采集全市场大盘指数+板块涨跌+上涨下跌家数。下游用于判断市场情绪。",
+ "writers": {
+ "mofin_db": "market_watch采集后写入快照数据",
+ },
+ "readers": {
+ "market_screener": "读取最新板块快照,判断热点板块",
+ "prepare_report_data": "读取市场情绪数据用于报告",
+ "mofin_db": "内部查询最新快照",
+ "system_audit": "审计数据新鲜度",
+ },
+ },
+ "sector_snapshots": {
+ "summary": "板块快照表,market_watch按板块写入涨跌/领涨股/资金流向。market_screener据此判断行业热点。",
+ "writers": {
+ "mofin_db": "market_watch采集后写入各板块数据",
+ },
+ "readers": {
+ "market_screener": "读取板块涨跌排名,筛选热点行业",
+ "strategy_lifecycle": "读取板块数据用于策略生命周期管理",
+ "mofin_db": "内部查询",
+ "trend_detector": "读取板块趋势数据用于趋势检测",
+ },
+ },
+ "sector_signals": {
+ "summary": "板块信号表,多源汇聚的板块级别信号(新闻情感+趋势+资金流向)。用于判断行业轮动。",
+ "writers": {
+ "mofin_news": "写入新闻分析得出的板块信号",
+ "xiaoguo_news_processor": "写入小果LLM分析的板块情感信号",
+ "trend_detector": "写入技术面趋势检测到的板块信号",
+ },
+ "readers": {
+ "server": "读取供Dashboard展示",
+ "mofin_news": "读取已有信号做增量更新",
+ "xiaoguo_news_processor": "读取已有信号避免重复写入",
+ "trend_detector": "读取信号辅助趋势判定",
+ },
+ },
+ "macro_context_log": {
+ "summary": "宏观上下文日志,refresh_macro_context每30分钟采集大盘指数/市场情绪/资金面数据。下游多个脚本按需读取最新宏观状态。",
+ "writers": {
+ "refresh_macro_context": "每30分钟采集上证/深证/创业板/恒指等指数+情绪指标",
+ },
+ "readers": {
+ "stale_push_wlin": "读取大盘情绪用于策略推送的宏观背景",
+ "divergence_detector": "读取多市场指数数据做背离检测",
+ "system_audit": "审计数据采集是否正常",
+ "xiaoguo_signal_consumer": "读取宏观情绪辅助信号判定",
+ },
+ },
+ "macro_raw_news": {
+ "summary": "宏观新闻原始数据表,macro_context_collector采集的未经处理的财经新闻。供后续清洗和分析。",
+ "writers": {
+ "macro_context_collector": "从财经网站采集原始新闻标题+URL+摘要",
+ },
+ "readers": {
+ "macro_context_collector": "读取最近新闻hash避免重复采集",
+ "system_audit": "审计新闻采集量",
+ },
+ },
+ "accuracy_stats": {
+ "summary": "策略准确率统计表,strategy_review复盘后写入各策略的正确/错误/待定计数。",
+ "writers": {
+ "strategy_review": "策略复盘后更新准确率统计",
+ },
+ "readers": {
+ "mofin_db": "读取统计结果用于报告",
+ },
+ },
+ "advice_timeline": {
+ "summary": "建议时间线表,记录每条推送建议的时间/内容/状态。用于审计和对账。",
+ "writers": {
+ "advice_reconciliation": "每周对账时写入对账结果",
+ },
+ "readers": {
+ "advice_reconciliation": "读取历史建议做对账",
+ "mofin_db": "内部查询",
+ },
+ },
+ "candidate_score_history": {
+ "summary": "候选股评分历史表,记录每次全市场筛选时对候选股的评分。用于评分变化追踪。",
+ "writers": {
+ "mofin_db": "market_screener筛选结果写入评分记录",
+ },
+ "readers": {
+ "mofin_db": "查询评分历史供展示",
+ },
+ },
+ "candidates": {
+ "summary": "候选股池表,market_screener筛选出的值得关注的个股。包含评分/买入区/止损/目标价。",
+ "writers": {
+ "mofin_db": "market_screener写入候选股",
+ "market_screener": "直接写入候选股列表",
+ },
+ "readers": {
+ "mofin_db": "读取候选股数据供展示和后续处理",
+ },
+ },
+ "capital_flow_cache": {
+ "summary": "资金流向缓存表,capital_flow_collector采集的板块资金流入流出数据。",
+ "writers": {
+ "mofin_db": "写入板块资金流向数据",
+ },
+ "readers": {
+ "mofin_db": "读取缓存数据",
+ },
+ },
+ "health_check_log": {
+ "summary": "健康检查日志表,morning_health_check每次运行记录检查结果。用于追踪系统健康历史。",
+ "writers": {
+ "morning_health_check": "每日开盘前体检后写入检查结果",
+ },
+ "readers": {
+ "morning_health_check": "读取历史检查结果比较变化",
+ },
+ },
+ "mtf_cache": {
+ "summary": "多周期技术指标缓存,refresh_mtf_cache计算MA5/MA20/MA60/支撑阻力位等。下游技术分析脚本从缓存读取避免重复计算。",
+ "writers": {
+ "multi_timeframe": "计算并写入多周期MA/支撑阻力位",
+ "mofin_db": "内部写入函数",
+ },
+ "readers": {
+ "multi_timeframe": "读取已有缓存判断是否需要刷新",
+ "technical_analysis": "读取MA/支撑阻力位用于技术分析",
+ "mofin_db": "内部读取",
+ },
+ },
+ "stock_fundamentals": {
+ "summary": "基本面数据表,存储PE/PB/ROE/市值等财务指标。",
+ "writers": {
+ "mofin_db": "基本面数据采集后写入",
+ },
+ "readers": {
+ "strategy_lifecycle": "读取基本面数据用于策略评估",
+ },
+ },
+ "stock_sectors": {
+ "summary": "股票-板块映射表,记录每只股票所属行业板块。多脚本用于行业分类和板块归因。",
+ "writers": {
+ "mofin_db": "股票行业分类数据写入",
+ },
+ "readers": {
+ "xiaoguo_news_processor": "按行业分类新闻",
+ "mofin_news": "按行业归类新闻",
+ "mofin_db": "内部查询",
+ "strategy_lifecycle": "读取行业信息用于策略决策",
+ },
+ },
+ "stocks": {
+ "summary": "全量股票代码表,所有A股/港股基础信息。供各脚本按code查询股票名称/市场。",
+ "writers": {
+ "mofin_db": "初始化时导入全量股票代码",
+ },
+ "readers": {
+ "mofin_news": "按股票代码查找新闻",
+ "xiaoguo_news_processor": "按股票代码过滤新闻",
+ "mofin_db": "内部查询",
+ "trend_detector": "按股票代码获取数据",
+ },
+ },
+ "strategy_evaluations": {
+ "summary": "策略评估结果表,策略评估脚本每次运行记录评估得分/等级/评语。",
+ "writers": {
+ "mofin_collect": "策略评估前采集数据并写入评估结果",
+ },
+ "readers": {
+ "verify_reassess_pipeline": "读取评估结果验证管道完整性",
+ "mofin_db": "内部查询",
+ "system_audit": "审计评估是否按时执行",
+ },
+ },
+ "strategy_feedback": {
+ "summary": "策略反馈表,记录用户对建议的反馈(采纳/忽略/修改)。用于策略自学习。",
+ "writers": {
+ "mofin_db": "写入反馈数据",
+ "server": "通过Web提交反馈后写入",
+ },
+ "readers": {
+ "mofin_db": "读取反馈用于分析和展示",
+ },
+ },
+ "todos": {
+ "summary": "待办事项表,各脚本发现异常时写入TODO,self_todo_executor每10分钟执行修复。异常发现→自动修复的闭环。",
+ "writers": {
+ "morning_health_check": "体检发现异常写入TODO",
+ "intraday_health_check": "盘中自检发现异常写入TODO",
+ "strategy-staleness-check": "策略过期检测写入TODO",
+ "self_todo_executor": "执行完成后更新TODO状态",
+ "preflight_verify": "开盘前验证失败写入TODO",
+ },
+ "readers": {
+ "morning_health_check": "读取待处理的TODO",
+ "self_todo_executor": "读取待处理的TODO并执行fix_action",
+ "strategy-staleness-check": "读取TODO避免重复写入",
+ "intraday_health_check": "读取TODO检查自愈进度",
+ },
+ },
+ "watchlist_stocks": {
+ "summary": "自选股表,系统自动维护的观察列表。与持仓表分离,用于跟踪潜在买入机会。",
+ "writers": {
+ "per_stock_reassess": "策略重评时更新自选状态",
+ "mofin_db": "内部写入函数",
+ },
+ "readers": {
+ "per_stock_reassess": "读取自选列表做重评",
+ "stock_quote": "读取自选代码拉取行情",
+ "mo_alphasift_bridge": "读取自选供Alpha分析",
+ "mo_data": "通过read_watchlist()读取自选数据",
+ },
+ },
+ "xiaoguo_scan_tracker": {
+ "summary": "小果扫描追踪表,记录每次小果扫描的状态/耗时/结果数量。用于监控小果服务健康。",
+ "writers": {
+ "xiaoguo_scanner": "每次扫描完成后写入状态和统计",
+ },
+ "readers": {
+ "server": "读取扫描状态供Dashboard展示",
+ "xiaoguo_scanner": "读取上次扫描时间判断是否需要全量扫描",
+ },
+ },
+ "state_meta": {
+ "summary": "状态元数据表,记录各服务的状态追踪信息(如扫描偏移量/最新处理ID)。",
+ "writers": {
+ "xiaoguo_scanner": "写入扫描进度偏移量",
+ },
+ "readers": {
+ "xiaoguo_scanner": "读取上次处理位置继续增量处理",
+ },
+ },
+}
+
+
+def build_feature_tree(cron_jobs, db_stats):
+ # 硬编码分类规则:标签→匹配关键词
+ rules = {
+ "市场快照": ["市场数据采集"],
+ "宏观新闻": ["宏观采集"],
+ "价格监控": ["价格监控"],
+ "小果扫描": ["小果独立扫描"],
+ "资金流采集": ["资金流"],
+ "宏观上下文刷新": ["宏观上下文刷新"],
+ "策略重评": ["策略重评"],
+ "持仓自选新鲜度检查": ["策略时效性检查"],
+ "自选买入区提醒": ["自选买入区提醒"],
+ "策略评估": ["策略评估"],
+ "分支自成长": ["分支自成长"],
+ "元自成长": ["元自成长"],
+ "MoFin盘前中监控": ["MoFin盘前中监控"],
+ "MoFin午后监控": ["MoFin午后监控"],
+ "cron报告推XMPP": ["cron报告推XMPP"],
+ "开盘简报": ["开盘简报"],
+ "收盘简报": ["收盘简报"],
+ "市场精选推荐": ["市场精选推荐"],
+ "小果情感分析": ["小果情感分析"],
+ "系统全局审计": ["系统全局审计"],
+ "全局cron健康监控": ["全局cron健康监控"],
+ "重评管道审计": ["重评管道审计"],
+ "健康监控数据采集": ["健康监控数据采集"],
+ "持仓基本面复查": ["分析师-持仓复查"],
+ "策略复盘": ["策略复盘"],
+ "宏观风险扫描": ["宏观风险扫描"],
+ "宏观风险信号消费": ["宏观风险信号消费"],
+ "跨市场背离检测": ["跨市场背离检测"],
+ "自愈执行器": ["自愈执行器"],
+ "策略质量门禁": ["策略质量门禁"],
+ "自选自动清理": ["自选自动清理"],
+ "建议对账": ["建议对账"],
+ "宏观新闻采集": ["宏观新闻采集"],
+ "数据治理": ["数据治理"],
+ "盘前热点扫描": ["盘前热点扫描"],
+ "数据同步": ["数据同步"],
+ "小果市场筛选": ["小果市场筛选"],
+ "芯碁微装": ["芯碁微装"],
+ "宏观新闻采集-周末": ["宏观新闻采集-周末"],
+ "硬编码扫描": ["硬编码扫描"],
+ "系统体检": ["系统体检"],
+ "盘中自检": ["盘中自检"],
+ "记忆守卫": ["记忆守卫"],
+ "数据治理": ["数据治理"],
+ "自选股自动重评": ["自选股自动重评"],
+ "state.db真空整理": ["真空整理"],
+ "300308": ["300308"],
+ "多周期缓存": ["多周期缓存"],
+ "元自成长": ["元自成长"],
+ }
+ # 自动归类:未被任何规则匹配的cron按名称关键词归入类别
+ # 关键词必须够精确,避免误归类
+ AUTO_CATEGORIES = [
+ ("数据采集", ["市场数据", "宏观采集", "新闻采集", "价格监控", "资金流采集", "小果独立扫描", "上下文刷新"]),
+ ("策略分析", ["策略评估", "策略时效性", "重评", "买入区提醒", "自成长", "策略复盘", "分支"]),
+ ("推荐推送", ["简报", "推送", "推荐", "XMPP", "开盘", "收盘"]),
+ ("风险监控", ["宏观风险", "背离检测", "信号消费"]),
+ ("自检/审计", ["系统全局审计", "健康监控", "管道审计", "系统体检", "盘中自检", "记忆守卫", "硬编码扫描", "治理"]),
+ ("执行/修复", ["自愈执行", "门禁", "清理", "对账", "TODO"]),
+ ("持仓监控", ["300308", "芯碁微装", "多周期缓存", "自选股自动重评"]),
+ ("系统服务", ["真空整理"]),
+ ]
+
+ matched_names = set() # 记录已匹配的cron name
+
+ def attach_pipes(node, parent_cat=None):
+ nonlocal matched_names
+ label = node.get("label", "")
+ # 附加描述(自动带脚本名的节点去掉括号内容匹配)
+ desc_key = label.split(" (")[0] if " (" in label else label
+ if desc_key in NODE_DESC:
+ node["desc"] = NODE_DESC[desc_key]
+ keywords = rules.get(label)
+ pipes = []
+ if keywords:
+ matched = match_cron(cron_jobs, keywords)
+ for j in matched:
+ n = j.get("name", "")
+ matched_names.add(n)
+ pipes = [{
+ "name": j.get("name", ""),
+ "script": j.get("script", ""),
+ "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))),
+ "status": j.get("last_status", "unknown"),
+ "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "",
+ "type": "no_agent" if j.get("no_agent") else "LLM",
+ "profile": j.get("profile", "?"),
+ } for j in matched]
+ if pipes:
+ node["pipes"] = pipes
+ if node.get("children"):
+ for c in node["children"]:
+ attach_pipes(c, parent_cat or label)
+
+ def make_cron_node(j):
+ name = j.get("name", "?")
+ desc_key = name.split(" (")[0] if " (" in name else name
+ return {
+ "label": f"{name} ({j.get('script','LLM')})",
+ "desc": NODE_DESC.get(desc_key, ""),
+ "status": j.get("last_status", "unknown"),
+ "pipes": [{
+ "name": j.get("name", ""),
+ "script": j.get("script", ""),
+ "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))),
+ "status": j.get("last_status", "unknown"),
+ "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "",
+ "type": "no_agent" if j.get("no_agent") else "LLM",
+ "profile": j.get("profile", "?"),
+ }]
+ }
+
+ tree = {
+ "label": "MoFin 系统",
+ "status": "ok",
+ "children": [
+ {"label": "数据采集", "status": "ok", "children": [
+ {"label": "市场快照", "status": "ok"},
+ {"label": "宏观新闻", "status": "ok"},
+ {"label": "价格监控", "status": "ok"},
+ {"label": "小果扫描", "status": "ok"},
+ {"label": "资金流采集", "status": "ok"},
+ {"label": "宏观上下文刷新", "status": "ok"},
+ ]},
+ {"label": "策略分析", "status": "ok", "children": [
+ {"label": "策略重评", "status": "ok"},
+ {"label": "持仓自选新鲜度检查", "status": "ok"},
+ {"label": "自选买入区提醒", "status": "ok"},
+ {"label": "策略评估", "status": "ok"},
+ {"label": "分支自成长", "status": "ok"},
+ {"label": "元自成长", "status": "ok"},
+ ]},
+ {"label": "推荐推送", "status": "ok", "children": [
+ {"label": "MoFin盘前中监控", "status": "ok"},
+ {"label": "MoFin午后监控", "status": "ok"},
+ {"label": "cron报告推XMPP", "status": "ok"},
+ {"label": "开盘简报", "status": "ok"},
+ {"label": "收盘简报", "status": "ok"},
+ {"label": "市场精选推荐", "status": "ok"},
+ ]},
+ {"label": "风险监控", "status": "ok", "children": [
+ {"label": "宏观风险扫描", "status": "ok"},
+ {"label": "宏观风险信号消费", "status": "ok"},
+ {"label": "跨市场背离检测", "status": "ok"},
+ ]},
+ {"label": "自检/审计", "status": "ok", "children": [
+ {"label": "系统全局审计", "status": "ok"},
+ {"label": "全局cron健康监控", "status": "ok"},
+ {"label": "重评管道审计", "status": "ok"},
+ {"label": "健康监控数据采集", "status": "ok"},
+ ]},
+ {"label": "执行/修复", "status": "ok", "children": [
+ {"label": "自愈执行器", "status": "ok"},
+ {"label": "策略质量门禁", "status": "ok"},
+ {"label": "自选自动清理", "status": "ok"},
+ {"label": "建议对账", "status": "ok"},
+ ]},
+ {"label": "持仓复查", "status": "ok", "children": [
+ {"label": "持仓基本面复查", "status": "ok"},
+ {"label": "策略复盘", "status": "ok"},
+ ]},
+ {"label": "信号消费", "status": "ok", "children": [
+ {"label": "小果情感分析", "status": "ok"},
+ {"label": "宏观风险信号消费-盘中", "status": "ok"},
+ ]},
+ ],
+ }
+
+ attach_pipes(tree)
+
+ # 收集所有未被任何规则匹配的cron,按名称自动归入类别
+ unmatched = [j for j in cron_jobs if j.get("name", "") not in matched_names]
+
+ # 按自动归类分组
+ cat_map = {}
+ for j in unmatched:
+ name = j.get("name", "")
+ assigned = False
+ for cat_name, keywords in AUTO_CATEGORIES:
+ if any(kw in name for kw in keywords):
+ cat_map.setdefault(cat_name, []).append(j)
+ assigned = True
+ break
+ if not assigned:
+ cat_map.setdefault("未分类", []).append(j)
+
+ # 将自动归类的cron追加到已有分类或创建新分类
+ for cat_name, jobs in sorted(cat_map.items()):
+ # 如果该分类已存在于树中,追加到其children
+ found = None
+ for child in tree["children"]:
+ if child["label"] == cat_name:
+ found = child
+ break
+ if found:
+ existing_labels = {c["label"] for c in found.get("children", [])}
+ for j in jobs:
+ lbl = j.get("name", "?")
+ if lbl not in existing_labels:
+ found["children"].append(make_cron_node(j))
+ existing_labels.add(lbl)
+ else:
+ tree["children"].append({
+ "label": cat_name,
+ "status": "ok",
+ "children": [make_cron_node(j) for j in jobs],
+ })
+
+ return tree
+
+def build_report():
+ cron_jobs = load_cron_jobs()
+ db_stats = get_db_stats()
+ flows = scan_data_flows()
+ script_health = check_scripts()
+
+ # ── 功能树(只显示知微的cron)──
+ zhiwei_crons = [j for j in cron_jobs if j.get("profile") == "position-analyst" or j.get("name") in [
+ "cron-推XMPP中继", "数据同步-dashboard", "记忆守卫-每日", "市场数据采集"
+ ]]
+ feature_tree = build_feature_tree(zhiwei_crons, db_stats)
+ # 递归计算节点状态
+ def calc_status(node):
+ if "children" in node:
+ for c in node["children"]:
+ calc_status(c)
+ statuses = [c["status"] for c in node["children"]]
+ if "fail" in statuses: node["status"] = "fail"
+ elif "warn" in statuses: node["status"] = "warn"
+ else: node["status"] = "ok"
+ calc_status(feature_tree)
+
+ # ── Tab 2: 数据实体表 ──
+ entities = []
+ for tname, cnt in sorted(db_stats.items()):
+ readers = flows["db_read"].get(tname, [])
+ writers = flows["db_write"].get(tname, [])
+ # 扫描器漏检的手动补录写入方
+ _manual_writers = {
+ "candidates": ["mofin_db", "market_screener"],
+ "candidate_score_history": ["mofin_db"],
+ "strategy_feedback": ["mofin_db", "server"],
+ "stock_daily": ["mofin_db"],
+ "stock_weekly": ["mofin_db"],
+ "stock_monthly": ["mofin_db"],
+ }
+ _manual_readers = {
+ "stock_weekly": ["multi_timeframe"],
+ "stock_monthly": ["multi_timeframe"],
+ "watchlist_log": ["watchlist_auto_exit", "mofin_db"],
+ }
+ if not writers and tname in _manual_writers:
+ writers = _manual_writers[tname]
+ if not readers and tname in _manual_readers:
+ readers = _manual_readers[tname]
+
+ # 数据流详细描述
+ flow_detail = FLOW_DETAIL.get(tname, {})
+
+ has_input = len(writers) > 0
+ has_output = len(readers) > 0
+ # 排除系统表
+ is_system = tname.startswith("sqlite_") or tname.startswith("_")
+ if is_system:
+ continue
+ # 数据流状态:healthy / write_only / read_only / orphan
+ if has_input and has_output:
+ flow_status = "healthy"
+ elif has_input and not has_output:
+ flow_status = "write_only"
+ elif not has_input and has_output:
+ flow_status = "read_only"
+ else:
+ flow_status = "orphan"
+ entities.append({
+ "name": tname,
+ "desc": TABLES_DESC.get(tname, ""),
+ "rows": cnt,
+ "readers": readers[:10],
+ "writers": writers[:10],
+ "has_input": has_input,
+ "has_output": has_output,
+ "orphan": flow_status in ("orphan", "read_only", "write_only"),
+ "flow_status": flow_status,
+ "warn": flow_status != "healthy",
+ "flow_detail": flow_detail,
+ })
+
+ # JSON文件
+ # 已迁移到DB的旧JSON文件:不再报"无读取方"假警报,真实健康信号看DB表新鲜度
+ MIGRATED_TO_DB = {
+ "multi_tf_cache.json": "mtf_cache",
+ "macro_context.json": "macro_context_log",
+ "market.json": "market_snapshots",
+ "live_prices.json": "live_prices",
+ "price_history.json": "price_events",
+ "macro_risk_state.json": "macro_context_log",
+ }
+ json_entities = []
+ for jf in sorted(WEB_DATA.glob("*.json")):
+ if jf.name == "stocks": continue
+ if jf.stem.startswith("temp_"): continue
+ readers = flows["json_read"].get(jf.name, [])
+ size = jf.stat().st_size / 1024
+ migrated = MIGRATED_TO_DB.get(jf.name)
+ desc = JSON_DESC.get(jf.name, "")
+ if migrated:
+ desc = (desc + " " if desc else "") + f"(已迁移到DB表 {migrated},此为遗留文件)"
+ json_entities.append({
+ "name": jf.name,
+ "desc": desc,
+ "size_kb": round(size, 1),
+ "readers": readers[:10],
+ "writers": [], # 难以精确追踪
+ "last_modified": datetime.fromtimestamp(jf.stat().st_mtime).strftime("%m-%d %H:%M"),
+ "warn": (len(readers) == 0 and jf.name not in ("portfolio.json", "market.json")
+ and not migrated),
+ "migrated_to_db": migrated or None,
+ })
+
+ # ── DB表新鲜度:真实数据管道健康信号(替代对遗留JSON文件的mtime检查)──
+ # 注意:活跃数据在 /home/hmo/MoFin/data/mofin.db(live_prices/mtf_cache 今日有写入),
+ # 不用 get_conn()(它指向 web-dashboard 的库,那边部分表是旧的)
+ db_freshness = []
+ FRESHNESS_TABLES = [
+ ("mtf_cache", "updated_at", "多周期均线缓存"),
+ ("macro_context_log", "created_at", "宏观上下文"),
+ ("market_snapshots", "created_at", "市场快照"),
+ ("live_prices", "updated_at", "实时价格"),
+ ("price_events", "created_at", "价格事件"),
+ ]
+ try:
+ _fc = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
+ for tname, tcol, label in FRESHNESS_TABLES:
+ try:
+ row = _fc.execute(
+ f"SELECT MAX({tcol}) FROM {tname}").fetchone()
+ if row and row[0]:
+ last_dt = datetime.fromisoformat(str(row[0]).replace("Z", ""))
+ age_h = (now - last_dt).total_seconds() / 3600
+ db_freshness.append({
+ "table": tname, "label": label,
+ "last_record": last_dt.strftime("%m-%d %H:%M"),
+ "age_hours": round(age_h, 1),
+ "warn": age_h > 24,
+ })
+ else:
+ db_freshness.append({"table": tname, "label": label,
+ "last_record": None, "age_hours": -1, "warn": True})
+ except Exception:
+ pass # 表不存在或列名不同,跳过
+ _fc.close()
+ except Exception:
+ pass
+
+ # ── Tab 3: 流程/cron映射 ──
+ pipelines = []
+ for j in sorted(cron_jobs, key=lambda x: x.get("name","")):
+ if not j.get("enabled", True):
+ continue
+ name = j.get("name", "?")
+ script = j.get("script", "")
+ status = j.get("last_status", "unknown")
+ last_run = str(j.get("last_run_at", ""))[:19]
+ schedule = j.get("schedule", {}).get("display", str(j.get("schedule","")))
+ no_agent = j.get("no_agent", False)
+ pipelines.append({
+ "name": name,
+ "type": "no_agent" if no_agent else "LLM",
+ "script": script,
+ "schedule": schedule,
+ "status": status,
+ "last_run": last_run,
+ "profile": j.get("profile", "?"),
+ })
+
+ # ── 写JSON ──
+ report = {
+ "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
+ "feature_tree": feature_tree,
+ "entities": entities,
+ "json_files": json_entities,
+ "pipelines": pipelines,
+ "db_freshness": db_freshness,
+ }
+ out_path = WEB_DATA / "mofin_health.json"
+ with open(out_path, "w") as f:
+ json.dump(report, f, ensure_ascii=False, indent=2)
+ # 也写到static目录供dashboard直接serve
+ with open(STATIC_DIR / "mofin_health.json", "w") as f:
+ json.dump(report, f, ensure_ascii=False, indent=2)
+ print(f"[SILENT] mofin_health.json written ({len(entities)} entities, {len(pipelines)} pipelines)")
+
+if __name__ == "__main__":
+ build_report()
diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py
index 46565351..f28e7896 100644
--- a/deploy/profile-scripts/price_monitor.py
+++ b/deploy/profile-scripts/price_monitor.py
@@ -1,732 +1,732 @@
-#!/usr/bin/env python3
-"""price_monitor.py — 高频价格监控脚本(批量版)
-规则:进入区间报一次,离开区间报一次,中间不重复。
-每次运行时一次性刷新所有持仓+自选股的实时价。
-"""
-import urllib.request
-import os, sys, time, json
-import sqlite3
-from datetime import datetime
-
-from mo_data import read_decisions
-
-BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
-STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json"
-
-# DB 模块(同步实时价到 mofin.db)
-sys.path.insert(0, "/home/hmo/MoFin")
-try:
- from mofin_db import get_conn, DB_PATH
- from mo_models import calc_total_mv, calc_total_assets
- HAS_DB = True
-except ImportError:
- HAS_DB = False
-
-# 策略重评依赖(技术面驱动,非机械百分比)
-sys.path.insert(0, "/home/hmo/web-dashboard")
-try:
- from strategy_lifecycle import reassess_strategy, reassess_with_context
- HAS_REASSESS = True
-except ImportError:
- HAS_REASSESS = False
-
-UA = "Mozilla/5.0"
-
-# ── XMPP推送 ──────────────────────────────────────────────────────────
-XMPP_USER = "hmo@yoin.fun"
-XMPP_BRIDGE = "http://127.0.0.1:5805/"
-
-def push_to_xmpp(text):
- """通过知微 HTTP bridge 推送到Dad私信"""
- if not text.strip():
- return
- try:
- payload = json.dumps({
- "to": XMPP_USER,
- "body": text.strip(),
- "type": "chat",
- }).encode("utf-8")
- req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"})
- urllib.request.urlopen(req, timeout=5)
- except Exception as e:
- print(f"[XMPP推送失败] {e}", file=sys.stderr)
-
-# ── 批量拉取价格 ──────────────────────────────────────────────────────────
-
-def fetch_all_prices(codes):
- """腾讯批量行情API:一次请求拉取所有股票(A股+港股)
- A股:sh600110 / sz000001
- 港股:hk00700
- 返回 {code: (price, change, change_pct)}
- """
- if not codes:
- return {}
-
- # 构建批量查询串
- symbols = []
- code_map = {} # symbol -> original_code
- for code in codes:
- code_s = str(code).strip()
- if len(code_s) == 6:
- # A股:沪市以5/6/9开头,深市以0/3开头
- if code_s.startswith(('5', '6', '9')):
- sym = f"sh{code_s}"
- else:
- sym = f"sz{code_s}"
- else:
- sym = f"hk{code_s}"
- symbols.append(sym)
- code_map[sym] = code_s
-
- url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
- try:
- req = urllib.request.Request(url, headers={"User-Agent": UA})
- with urllib.request.urlopen(req, timeout=10) as r:
- text = r.read().decode("gbk")
- except Exception as e:
- print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr)
- return {}
-
- results = {}
- for line in text.strip().split("\n"):
- line = line.strip()
- if not line or "=" not in line:
- continue
- try:
- # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..."
- raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
- fields = raw_value.split("~")
- if len(fields) < 6:
- continue
- sym = line.split("=", 1)[0].strip().lstrip("v_")
- orig_code = code_map.get(sym)
- if not orig_code:
- continue
- price = float(fields[3]) if fields[3] else 0
- prev_close = float(fields[4]) if fields[4] else 0
- change = price - prev_close if prev_close > 0 else 0
- change_pct = fields[32] if len(fields) > 32 and fields[32] else "0"
- results[orig_code] = (price, change, change_pct)
- except (ValueError, IndexError):
- continue
-
- return results
-
-
-def refresh_data_prices():
- """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)"""
- all_codes = set()
-
- # 从DB读所有需要拉取价格的代码
- try:
- conn = get_conn()
- for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"):
- all_codes.add(r['code'])
- for r in conn.execute("SELECT code FROM watchlist_stocks"):
- all_codes.add(r['code'])
- for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"):
- all_codes.add(r['code'])
- conn.close()
- except Exception as e:
- print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr)
- return 0
-
- if not all_codes:
- return 0
-
- # 一次性批量拉取
- prices = fetch_all_prices(list(all_codes))
- updated = len(prices)
-
- # === 弹性同步实时价到 mofin.db ===
- # 防死锁策略(经2026-07-14 WAL死锁复盘改进):
- # ① 启动时 checkpoint WAL(清理残留事务)
- # ② 统一 BEGIN IMMEDIATE 包裹整个写操作
- # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s)
- # ④ get_conn() 的 busy_timeout=30000 保证等待上限
- # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试
- # ⑥ try/finally 确保连接始终释放
- if HAS_DB and prices:
- # 先checkpoint一次,清理上次被kill残留的WAL
- try:
- c = get_conn()
- c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
- c.close()
- except Exception:
- pass
-
- max_tries = 5
- conn = None
- for db_attempt in range(max_tries):
- try:
- conn = get_conn()
- # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s)
- conn.execute("BEGIN IMMEDIATE")
-
- # ── 构建 holdings 更新数据 ──
- db_holdings = []
- for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"):
- h = dict(r)
- code = str(h.get('code', ''))
- if code in prices:
- price_val, _, change_pct = prices[code]
- if price_val > 0:
- h['price'] = round(price_val, 2)
- h['change_pct'] = float(change_pct) if change_pct else 0
- db_holdings.append(h)
-
- # ── 写 holdings 表 ──
- for h in db_holdings:
- currency = str(h.get('currency', 'CNY')).upper()
- if currency not in ('CNY', 'HKD'):
- raise ValueError(f"非法币种: {currency}")
- conn.execute("""
- INSERT INTO holdings (code, name, shares, cost, price, market_value,
- change_pct, currency, position_pct, added_at, is_active)
- VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1)
- ON CONFLICT(code) DO UPDATE SET
- name=excluded.name, shares=excluded.shares, cost=excluded.cost,
- price=excluded.price, market_value=excluded.market_value,
- change_pct=excluded.change_pct, currency=excluded.currency,
- position_pct=excluded.position_pct
- """, (
- h.get('code'), h.get('name'), h.get('shares', 0),
- h.get('cost'), h.get('price'),
- h.get('market_value'), h.get('change_pct'),
- h.get('currency', 'CNY'), h.get('position_pct'),
- ))
-
- # ── 写 portfolio_summary ──
- mv = calc_total_mv(db_holdings)
- existing = conn.execute(
- 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1'
- ).fetchone()
- db_cash = existing['cash'] if existing else 0.0
- db_frozen = existing['frozen_cash'] if existing else 0.0
- assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen})
- position_pct = round(mv / assets * 100, 2) if assets > 0 else 0
- conn.execute("""
- INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
- cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
- VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime'))
- ON CONFLICT(id) DO UPDATE SET
- total_assets=excluded.total_assets, total_mv=excluded.total_mv,
- stock_value=excluded.stock_value, cash=excluded.cash,
- frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct,
- total_pnl=excluded.total_pnl, currency=excluded.currency,
- updated_at=datetime('now','localtime')
- """, (
- assets, mv, mv, db_cash, db_frozen,
- position_pct, 0, 'CNY',
- ))
-
- # ── 写 live_prices ──
- for h in db_holdings:
- code = h.get('code', '')
- if code:
- p = h.get('price', 0)
- cp = h.get('change_pct', 0)
- conn.execute(
- "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
- "VALUES (?,?,?,datetime('now','localtime'))",
- (code, p, cp)
- )
- # 补充策略股/自选股的价格(不在holdings中的)
- for code, pdata in prices.items():
- if code not in {h.get('code') for h in db_holdings}:
- price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0)
- cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0)
- conn.execute(
- "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
- "VALUES (?,?,?,datetime('now','localtime'))",
- (code, price_val, cp_val)
- )
-
- conn.commit()
- conn.close()
- conn = None
- if db_attempt > 0:
- print(f"DB同步成功(第{db_attempt+1}次重试)")
- break # success
-
- except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
- if conn:
- try: conn.rollback()
- except Exception: pass
- try: conn.close()
- except Exception: pass
- conn = None
- err_str = str(e)
- if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str:
- if db_attempt < max_tries - 1:
- wait = 2 ** db_attempt # 1, 2, 4, 8, 16
- print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr)
- time.sleep(wait)
- else:
- print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr)
- else:
- print(f"❌ DB错误: {e}", file=sys.stderr)
- break
- except Exception as e:
- if conn:
- try: conn.rollback()
- except Exception: pass
- try: conn.close()
- except Exception: pass
- conn = None
- print(f"⚠️ DB同步异常: {e}", file=sys.stderr)
- break
- else:
- # for-else: loop exhausted without break
- print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr)
- # 尝试紧急 WAL checkpoint(释放死锁)
- try:
- c = sqlite3.connect(str(DB_PATH), timeout=1)
- c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
- c.close()
- print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr)
- except Exception as we:
- print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr)
-
- return updated
-
-
-# ── 区间偏离检测 ──────────────────────────────────────────────────────────
-
-def load_state():
- try:
- with open(STATE_PATH) as f:
- return json.load(f)
- except:
- return {}
-
-def save_state(state):
- os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
- with open(STATE_PATH, 'w') as f:
- json.dump(state, f, ensure_ascii=False, indent=2)
-
-def load_breaches():
- try:
- with open(BREACH_PATH) as f:
- return json.load(f)
- except:
- return {}
-
-def save_breaches(data):
- os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True)
- with open(BREACH_PATH, 'w') as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
-
-
-def record_event(code, name, event_type, price, trigger_value, event_label=""):
- """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。
-
- price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股)
- 先注册再写事件,否则 FK 失败事件丢失。
- """
- now = datetime.now().isoformat()
-
- if HAS_DB:
- try:
- from mofin_db import get_conn, write_price_event
- _c = get_conn()
- _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A"))
- _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)",
- (str(code), name or str(code), _exch, _typ, now))
- _c.commit()
- write_price_event(_c, code=code, name=name, event_type=event_type,
- price=round(price, 2), trigger_value=str(trigger_value),
- event_label=event_label)
- _c.close()
- except Exception as e:
- print(f"[price_events DB写入失败] {e}", file=sys.stderr)
-
-
-def get_trigger_zones(trigger):
- """返回该trigger所有可监控的区间列表,跳过已执行的batch"""
- zones = []
- for key, label in [
- ("entry_zone", "加仓区间"),
- ("batch1_price", "试仓区间"),
- ("batch2_price", "加仓区间"),
- ("take_profit_zone", "止盈区间"),
- ("watch_low", "关注区间"),
- ("watch_high", "减仓区间"),
- ("watch_break", "止损区间")
- ]:
- status_key = key.replace("_price", "_status")
- if status_key in trigger and trigger[status_key] == "executed":
- continue
- val = trigger.get(key, "")
- if val and "~" in val:
- try:
- parts = val.split("~")
- lo, hi = float(parts[0]), float(parts[1])
- zones.append((key, label, lo, hi))
- except:
- pass
- sl = trigger.get("stop_loss", "")
- if sl:
- try:
- sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl)
- zones.append(("stop_loss", "止损", 0, sl_price))
- except:
- pass
- return zones
-
-
-def _cleanup_lock():
- """清理进程锁文件"""
- try:
- os.remove("/tmp/price_monitor.lock")
- except Exception:
- pass
-
-def _handle_sigterm(signum, frame):
- """收到SIGTERM时清理锁文件后退出"""
- _cleanup_lock()
- sys.exit(0)
-
-def run_once(round_label=""):
- """执行一轮完整的监控流程"""
- import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖
- signal.signal(signal.SIGTERM, _handle_sigterm)
- os.nice(10) # 降低优先级,避免与DB其他写操作抢占
- # ── 进程锁:同一时间只跑一个实例 ──
- _lk = "/tmp/price_monitor.lock"
- _pid = None
- try:
- with open(_lk) as _f:
- _pid = int(_f.read().strip())
- os.kill(_pid, 0)
- print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True)
- return
- except (FileNotFoundError, ProcessLookupError, ValueError):
- pass
- with open(_lk, "w") as _f:
- _f.write(str(os.getpid()))
-
- label = f" [{round_label}]" if round_label else ""
- start = time.time()
- TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑
-
- # === 第一步:一次性刷新所有价格 ===
- refreshed = refresh_data_prices()
-
- # === 第二步:检查触发条件 ===
- try:
- dec = read_decisions()
- except:
- print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr)
- return
-
- active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
- state = load_state()
- outputs = []
- state_updated = False
- # 时间冷却:同股同区间30分钟内不重复推
- _push_cooldown = {}
- _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json"
- try:
- import os
- if os.path.exists(_cooldown_file):
- with open(_cooldown_file) as _f:
- _push_cooldown = json.load(_f)
- except Exception:
- _push_cooldown = {}
-
- def _can_push(code, zone_key):
- now = time.time()
- key = f"{code}_{zone_key}"
- last = _push_cooldown.get(key, 0)
- if now - last < 1800: # 30分钟
- return False
- _push_cooldown[key] = now
- # 持久化写入
- try:
- with open(_cooldown_file, "w") as _f:
- json.dump(_push_cooldown, _f)
- except Exception:
- pass
- return True
-
- # 收集所有需要检查的代码
- check_codes = set()
- for d in active:
- trig = d.get("trigger", {})
- if trig:
- check_codes.add(d["code"])
-
- # 批量拉取这些股票的价格
- prices = fetch_all_prices(list(check_codes))
-
- for d in active:
- code = d["code"]
- trig = d.get("trigger", {})
- if not trig:
- continue
-
- zones = get_trigger_zones(trig)
- if not zones:
- continue
-
- price_info = prices.get(code)
- if not price_info:
- continue
- price, _, _ = price_info
- if price == 0:
- continue
-
- name = d.get("name", code)
- if code not in state:
- state[code] = {}
-
- # 时间预算检查:如果超时,跳过重评只做状态记录
- _budget_low = (time.time() - start) > TIME_BUDGET
-
- for key, label, lo, hi in zones:
- in_zone = lo <= price <= hi
- prev_in_zone = state[code].get(key, None)
-
- if in_zone and prev_in_zone != True:
- if key == "stop_loss":
- outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
- record_event(code, name, "stop_loss", price, str(hi))
- # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警)
- if _budget_low:
- outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad")
- if _can_push(code, "stop_loss"):
- push_to_xmpp(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
- else:
- try:
- cost = d.get("cost", 0) or 0
- shares = d.get("shares", 0) or 0
- current_action = d.get("action", "")
- result = reassess_with_context(code, name, price, cost, shares, current_action)
- if result:
- timing_signal = result.get("timing_signal", "")
- action = result.get("action", "")
- if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
- buy_lo = d.get("entry_low", 0)
- buy_hi = d.get("entry_high", 0)
- rr = result.get("rr_ratio", 0)
- if _can_push(code, "stop_loss"):
- msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}"
- push_to_xmpp(msg)
- outputs.append(f" 📨 止损重评→已推送Dad: {action}")
- except Exception as e:
- outputs.append(f" ⚠️ 止损重评失败: {e}")
- else:
- extra = ""
- if "_price" in key:
- batch_shares = trig.get(key.replace("_price", "_shares"), "")
- action = trig.get(key.replace("_price", "_action"), "")
- if batch_shares:
- extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股"
- elif key in ("take_profit_zone",):
- act = trig.get("take_profit_action", "")
- if act:
- extra = f"({act})"
- outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
- record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
- # 进入区间 → 立即重评并推送给Dad(时间不够则跳过重评直接推原始告警)
- if _budget_low:
- if _can_push(code, key):
- push_to_xmpp(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}")
- outputs.append(f" 📨 区间触发(超时跳过重评)→已推送Dad")
- else:
- try:
- cost = d.get("cost", 0) or 0
- shares = d.get("shares", 0) or 0
- current_action = d.get("action", "")
- result = reassess_with_context(code, name, price, cost, shares, current_action)
- if result:
- timing_signal = result.get("timing_signal", "")
- action = result.get("action", "")
- # 格式化区间描述(止盈区lo=0时美化显示)
- if key == "take_profit_zone" and lo == 0:
- zone_desc = f"止盈监控(目标{hi:.0f})"
- else:
- zone_desc = f"操作区间{lo}~{hi}"
- if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
- rr = result.get("rr_ratio", 0)
- if _can_push(code, key):
- msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}"
- push_to_xmpp(msg)
- outputs.append(f" 📨 区间触发重评→已推送Dad: {action}")
- else:
- reason = f"重评结果:{timing_signal},不构成操作建议"
- outputs.append(f" 📋 本地日志(不推): {reason}")
- except Exception as e:
- outputs.append(f" ⚠️ 区间重评失败: {e}")
- state[code][key] = True
- state_updated = True
-
- elif not in_zone and prev_in_zone == True:
- if key != "stop_loss":
- outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}")
- state[code][key] = False
- state_updated = True
-
- # === 第三步:买入区偏离检测 + 自动重评 ===
- reassesed_codes = []
- # 先做急跌检测(仅持仓,自选股不推送暴跌告警)
- holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0}
- for d in active:
- code = d["code"]
- # 非持仓跳过
- if code not in holdings_codes:
- continue
- name = d.get("name", code)
- price_info = prices.get(code)
- if not price_info:
- continue
- price, _, change_pct = price_info
- if price == 0:
- continue
- # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发)
- try:
- cp = float(change_pct) if change_pct else 0
- except:
- cp = 0
- if cp <= -7:
- prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False)
- if not prev_alert:
- stop_loss = d.get("stop_loss", 0)
- sl_note = f" 止损{stop_loss}" if stop_loss else ""
- msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}"
- push_to_xmpp(msg)
- outputs.append(msg)
- state.setdefault(code, {})["__sharp_decline_triggered"] = True
- state_updated = True
- # 立即持久化,防止后续超时导致状态丢失而重复推送
- save_state(state)
- elif cp > -5:
- # 反弹后清除告警标记,下次再跌还能报
- state.setdefault(code, {}).pop("__sharp_decline_triggered", None)
-
- for d in active:
- code = d["code"]
- name = d.get("name", code)
- price_info = prices.get(code)
- if not price_info:
- continue
- price, _, _ = price_info
- if price == 0:
- continue
-
- # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区
- entry_low = d.get("entry_low", 0)
- entry_high = d.get("entry_high", 0)
- if not entry_low or not entry_high:
- continue
-
- in_buy_zone = entry_low <= price <= entry_high
- prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None)
-
- # 状态变化时才触发
- if in_buy_zone and prev_in_buy_zone == False:
- # 重新进入买入区 → 重评确认区间是否仍然有效
- outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评")
- do_reassess = True
- elif not in_buy_zone and prev_in_buy_zone == True:
- # 离开买入区 → 立即重评,更新止损/止盈/区间
- outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评")
- do_reassess = True
- else:
- do_reassess = False
-
- if do_reassess and HAS_REASSESS:
- try:
- cost = d.get("cost", 0) or 0
- shares = d.get("shares", 0) or 0
- profit_pct = (price - cost) / cost * 100 if cost else 0
- is_deep_loss = profit_pct < -20
- sentiment = "neutral"
- if d.get("tech_snapshot"):
- if "bearish" in d["tech_snapshot"]:
- sentiment = "bearish"
- elif "bullish" in d["tech_snapshot"]:
- sentiment = "bullish"
-
- # 调用技术面驱动重评(非机械百分比)
- result = reassess_strategy(
- code, name, price, cost, shares,
- current_action=d.get("action", ""),
- volume_signal="中性", sentiment=sentiment,
- )
- outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}")
- reassesed_codes.append(code)
- except Exception as e:
- outputs.append(f" ⚠️ 重评失败: {e}")
-
- # 更新买入区状态
- if "__buy_zone" not in state.get(code, {}):
- if code not in state:
- state[code] = {}
- state[code]["__buy_zone"] = in_buy_zone
- state_updated = True
-
- # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃)
- if reassesed_codes and HAS_REASSESS:
- # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ──
- _regen_marker = "/tmp/price_monitor_regen_at"
- _skip_regen = False
- try:
- if os.path.exists(_regen_marker):
- with open(_regen_marker) as _f:
- _last_regen = float(_f.read().strip())
- if time.time() - _last_regen < 300:
- _skip_regen = True
- except:
- pass
-
- if _skip_regen:
- outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑")
- else:
- try:
- from strategy_lifecycle import regenerate_all
- r = regenerate_all(stdout=False)
- outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功")
- outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}")
- try:
- with open(_regen_marker, "w") as _f:
- _f.write(str(time.time()))
- except:
- pass
- except Exception as e:
- outputs.append(f" ⚠️ 全量重评失败: {e}")
-
- # === 第四步:输出 ===
- now_str = datetime.now().strftime("%H:%M:%S")
- elapsed = time.time() - start
-
- if outputs:
- print(f"\n🔔 {now_str}{label}")
- for o in outputs:
- print(o)
- print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}")
- else:
- # 无触发时 SILENT(中继不推送)
- print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s")
-
- if state_updated:
- save_state(state)
-
- # 输出耗时
- print(f"⏱{label} {elapsed:.1f}s", flush=True)
-
- # 清理进程锁
- try:
- os.remove("/tmp/price_monitor.lock")
- except Exception:
- pass
-
-
-def main():
- """每cron触发跑一轮"""
- run_once()
-
-
-if __name__ == "__main__":
- main()
+#!/usr/bin/env python3
+"""price_monitor.py — 高频价格监控脚本(批量版)
+规则:进入区间报一次,离开区间报一次,中间不重复。
+每次运行时一次性刷新所有持仓+自选股的实时价。
+"""
+import urllib.request
+import os, sys, time, json
+import sqlite3
+from datetime import datetime
+
+from mo_data import read_decisions
+
+BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
+STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json"
+
+# DB 模块(同步实时价到 mofin.db)
+sys.path.insert(0, "/home/hmo/MoFin")
+try:
+ from mofin_db import get_conn, DB_PATH
+ from mo_models import calc_total_mv, calc_total_assets
+ HAS_DB = True
+except ImportError:
+ HAS_DB = False
+
+# 策略重评依赖(技术面驱动,非机械百分比)
+sys.path.insert(0, "/home/hmo/web-dashboard")
+try:
+ from strategy_lifecycle import reassess_strategy, reassess_with_context
+ HAS_REASSESS = True
+except ImportError:
+ HAS_REASSESS = False
+
+UA = "Mozilla/5.0"
+
+# ── XMPP推送 ──────────────────────────────────────────────────────────
+XMPP_USER = "hmo@yoin.fun"
+XMPP_BRIDGE = "http://127.0.0.1:5805/"
+
+def push_to_xmpp(text):
+ """通过知微 HTTP bridge 推送到Dad私信"""
+ if not text.strip():
+ return
+ try:
+ payload = json.dumps({
+ "to": XMPP_USER,
+ "body": text.strip(),
+ "type": "chat",
+ }).encode("utf-8")
+ req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"})
+ urllib.request.urlopen(req, timeout=5)
+ except Exception as e:
+ print(f"[XMPP推送失败] {e}", file=sys.stderr)
+
+# ── 批量拉取价格 ──────────────────────────────────────────────────────────
+
+def fetch_all_prices(codes):
+ """腾讯批量行情API:一次请求拉取所有股票(A股+港股)
+ A股:sh600110 / sz000001
+ 港股:hk00700
+ 返回 {code: (price, change, change_pct)}
+ """
+ if not codes:
+ return {}
+
+ # 构建批量查询串
+ symbols = []
+ code_map = {} # symbol -> original_code
+ for code in codes:
+ code_s = str(code).strip()
+ if len(code_s) == 6:
+ # A股:沪市以5/6/9开头,深市以0/3开头
+ if code_s.startswith(('5', '6', '9')):
+ sym = f"sh{code_s}"
+ else:
+ sym = f"sz{code_s}"
+ else:
+ sym = f"hk{code_s}"
+ symbols.append(sym)
+ code_map[sym] = code_s
+
+ url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
+ with urllib.request.urlopen(req, timeout=10) as r:
+ text = r.read().decode("gbk")
+ except Exception as e:
+ print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr)
+ return {}
+
+ results = {}
+ for line in text.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ try:
+ # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..."
+ raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
+ fields = raw_value.split("~")
+ if len(fields) < 6:
+ continue
+ sym = line.split("=", 1)[0].strip().lstrip("v_")
+ orig_code = code_map.get(sym)
+ if not orig_code:
+ continue
+ price = float(fields[3]) if fields[3] else 0
+ prev_close = float(fields[4]) if fields[4] else 0
+ change = price - prev_close if prev_close > 0 else 0
+ change_pct = fields[32] if len(fields) > 32 and fields[32] else "0"
+ results[orig_code] = (price, change, change_pct)
+ except (ValueError, IndexError):
+ continue
+
+ return results
+
+
+def refresh_data_prices():
+ """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)"""
+ all_codes = set()
+
+ # 从DB读所有需要拉取价格的代码
+ try:
+ conn = get_conn()
+ for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"):
+ all_codes.add(r['code'])
+ for r in conn.execute("SELECT code FROM watchlist_stocks"):
+ all_codes.add(r['code'])
+ for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"):
+ all_codes.add(r['code'])
+ conn.close()
+ except Exception as e:
+ print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr)
+ return 0
+
+ if not all_codes:
+ return 0
+
+ # 一次性批量拉取
+ prices = fetch_all_prices(list(all_codes))
+ updated = len(prices)
+
+ # === 弹性同步实时价到 mofin.db ===
+ # 防死锁策略(经2026-07-14 WAL死锁复盘改进):
+ # ① 启动时 checkpoint WAL(清理残留事务)
+ # ② 统一 BEGIN IMMEDIATE 包裹整个写操作
+ # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s)
+ # ④ get_conn() 的 busy_timeout=30000 保证等待上限
+ # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试
+ # ⑥ try/finally 确保连接始终释放
+ if HAS_DB and prices:
+ # 先checkpoint一次,清理上次被kill残留的WAL
+ try:
+ c = get_conn()
+ c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+ c.close()
+ except Exception:
+ pass
+
+ max_tries = 5
+ conn = None
+ for db_attempt in range(max_tries):
+ try:
+ conn = get_conn()
+ # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s)
+ conn.execute("BEGIN IMMEDIATE")
+
+ # ── 构建 holdings 更新数据 ──
+ db_holdings = []
+ for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"):
+ h = dict(r)
+ code = str(h.get('code', ''))
+ if code in prices:
+ price_val, _, change_pct = prices[code]
+ if price_val > 0:
+ h['price'] = round(price_val, 2)
+ h['change_pct'] = float(change_pct) if change_pct else 0
+ db_holdings.append(h)
+
+ # ── 写 holdings 表 ──
+ for h in db_holdings:
+ currency = str(h.get('currency', 'CNY')).upper()
+ if currency not in ('CNY', 'HKD'):
+ raise ValueError(f"非法币种: {currency}")
+ conn.execute("""
+ INSERT INTO holdings (code, name, shares, cost, price, market_value,
+ change_pct, currency, position_pct, added_at, is_active)
+ VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1)
+ ON CONFLICT(code) DO UPDATE SET
+ name=excluded.name, shares=excluded.shares, cost=excluded.cost,
+ price=excluded.price, market_value=excluded.market_value,
+ change_pct=excluded.change_pct, currency=excluded.currency,
+ position_pct=excluded.position_pct
+ """, (
+ h.get('code'), h.get('name'), h.get('shares', 0),
+ h.get('cost'), h.get('price'),
+ h.get('market_value'), h.get('change_pct'),
+ h.get('currency', 'CNY'), h.get('position_pct'),
+ ))
+
+ # ── 写 portfolio_summary ──
+ mv = calc_total_mv(db_holdings)
+ existing = conn.execute(
+ 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1'
+ ).fetchone()
+ db_cash = existing['cash'] if existing else 0.0
+ db_frozen = existing['frozen_cash'] if existing else 0.0
+ assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen})
+ position_pct = round(mv / assets * 100, 2) if assets > 0 else 0
+ conn.execute("""
+ INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
+ cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
+ VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime'))
+ ON CONFLICT(id) DO UPDATE SET
+ total_assets=excluded.total_assets, total_mv=excluded.total_mv,
+ stock_value=excluded.stock_value, cash=excluded.cash,
+ frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct,
+ total_pnl=excluded.total_pnl, currency=excluded.currency,
+ updated_at=datetime('now','localtime')
+ """, (
+ assets, mv, mv, db_cash, db_frozen,
+ position_pct, 0, 'CNY',
+ ))
+
+ # ── 写 live_prices ──
+ for h in db_holdings:
+ code = h.get('code', '')
+ if code:
+ p = h.get('price', 0)
+ cp = h.get('change_pct', 0)
+ conn.execute(
+ "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
+ "VALUES (?,?,?,datetime('now','localtime'))",
+ (code, p, cp)
+ )
+ # 补充策略股/自选股的价格(不在holdings中的)
+ for code, pdata in prices.items():
+ if code not in {h.get('code') for h in db_holdings}:
+ price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0)
+ cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0)
+ conn.execute(
+ "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
+ "VALUES (?,?,?,datetime('now','localtime'))",
+ (code, price_val, cp_val)
+ )
+
+ conn.commit()
+ conn.close()
+ conn = None
+ if db_attempt > 0:
+ print(f"DB同步成功(第{db_attempt+1}次重试)")
+ break # success
+
+ except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
+ if conn:
+ try: conn.rollback()
+ except Exception: pass
+ try: conn.close()
+ except Exception: pass
+ conn = None
+ err_str = str(e)
+ if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str:
+ if db_attempt < max_tries - 1:
+ wait = 2 ** db_attempt # 1, 2, 4, 8, 16
+ print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr)
+ time.sleep(wait)
+ else:
+ print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr)
+ else:
+ print(f"❌ DB错误: {e}", file=sys.stderr)
+ break
+ except Exception as e:
+ if conn:
+ try: conn.rollback()
+ except Exception: pass
+ try: conn.close()
+ except Exception: pass
+ conn = None
+ print(f"⚠️ DB同步异常: {e}", file=sys.stderr)
+ break
+ else:
+ # for-else: loop exhausted without break
+ print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr)
+ # 尝试紧急 WAL checkpoint(释放死锁)
+ try:
+ c = sqlite3.connect(str(DB_PATH), timeout=1)
+ c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+ c.close()
+ print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr)
+ except Exception as we:
+ print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr)
+
+ return updated
+
+
+# ── 区间偏离检测 ──────────────────────────────────────────────────────────
+
+def load_state():
+ try:
+ with open(STATE_PATH) as f:
+ return json.load(f)
+ except:
+ return {}
+
+def save_state(state):
+ os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
+ with open(STATE_PATH, 'w') as f:
+ json.dump(state, f, ensure_ascii=False, indent=2)
+
+def load_breaches():
+ try:
+ with open(BREACH_PATH) as f:
+ return json.load(f)
+ except:
+ return {}
+
+def save_breaches(data):
+ os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True)
+ with open(BREACH_PATH, 'w') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+def record_event(code, name, event_type, price, trigger_value, event_label=""):
+ """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。
+
+ price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股)
+ 先注册再写事件,否则 FK 失败事件丢失。
+ """
+ now = datetime.now().isoformat()
+
+ if HAS_DB:
+ try:
+ from mofin_db import get_conn, write_price_event
+ _c = get_conn()
+ _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A"))
+ _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)",
+ (str(code), name or str(code), _exch, _typ, now))
+ _c.commit()
+ write_price_event(_c, code=code, name=name, event_type=event_type,
+ price=round(price, 2), trigger_value=str(trigger_value),
+ event_label=event_label)
+ _c.close()
+ except Exception as e:
+ print(f"[price_events DB写入失败] {e}", file=sys.stderr)
+
+
+def get_trigger_zones(trigger):
+ """返回该trigger所有可监控的区间列表,跳过已执行的batch"""
+ zones = []
+ for key, label in [
+ ("entry_zone", "加仓区间"),
+ ("batch1_price", "试仓区间"),
+ ("batch2_price", "加仓区间"),
+ ("take_profit_zone", "止盈区间"),
+ ("watch_low", "关注区间"),
+ ("watch_high", "减仓区间"),
+ ("watch_break", "止损区间")
+ ]:
+ status_key = key.replace("_price", "_status")
+ if status_key in trigger and trigger[status_key] == "executed":
+ continue
+ val = trigger.get(key, "")
+ if val and "~" in val:
+ try:
+ parts = val.split("~")
+ lo, hi = float(parts[0]), float(parts[1])
+ zones.append((key, label, lo, hi))
+ except:
+ pass
+ sl = trigger.get("stop_loss", "")
+ if sl:
+ try:
+ sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl)
+ zones.append(("stop_loss", "止损", 0, sl_price))
+ except:
+ pass
+ return zones
+
+
+def _cleanup_lock():
+ """清理进程锁文件"""
+ try:
+ os.remove("/tmp/price_monitor.lock")
+ except Exception:
+ pass
+
+def _handle_sigterm(signum, frame):
+ """收到SIGTERM时清理锁文件后退出"""
+ _cleanup_lock()
+ sys.exit(0)
+
+def run_once(round_label=""):
+ """执行一轮完整的监控流程"""
+ import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖
+ signal.signal(signal.SIGTERM, _handle_sigterm)
+ os.nice(10) # 降低优先级,避免与DB其他写操作抢占
+ # ── 进程锁:同一时间只跑一个实例 ──
+ _lk = "/tmp/price_monitor.lock"
+ _pid = None
+ try:
+ with open(_lk) as _f:
+ _pid = int(_f.read().strip())
+ os.kill(_pid, 0)
+ print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True)
+ return
+ except (FileNotFoundError, ProcessLookupError, ValueError):
+ pass
+ with open(_lk, "w") as _f:
+ _f.write(str(os.getpid()))
+
+ label = f" [{round_label}]" if round_label else ""
+ start = time.time()
+ TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑
+
+ # === 第一步:一次性刷新所有价格 ===
+ refreshed = refresh_data_prices()
+
+ # === 第二步:检查触发条件 ===
+ try:
+ dec = read_decisions()
+ except:
+ print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr)
+ return
+
+ active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
+ state = load_state()
+ outputs = []
+ state_updated = False
+ # 时间冷却:同股同区间30分钟内不重复推
+ _push_cooldown = {}
+ _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json"
+ try:
+ import os
+ if os.path.exists(_cooldown_file):
+ with open(_cooldown_file) as _f:
+ _push_cooldown = json.load(_f)
+ except Exception:
+ _push_cooldown = {}
+
+ def _can_push(code, zone_key):
+ now = time.time()
+ key = f"{code}_{zone_key}"
+ last = _push_cooldown.get(key, 0)
+ if now - last < 1800: # 30分钟
+ return False
+ _push_cooldown[key] = now
+ # 持久化写入
+ try:
+ with open(_cooldown_file, "w") as _f:
+ json.dump(_push_cooldown, _f)
+ except Exception:
+ pass
+ return True
+
+ # 收集所有需要检查的代码
+ check_codes = set()
+ for d in active:
+ trig = d.get("trigger", {})
+ if trig:
+ check_codes.add(d["code"])
+
+ # 批量拉取这些股票的价格
+ prices = fetch_all_prices(list(check_codes))
+
+ for d in active:
+ code = d["code"]
+ trig = d.get("trigger", {})
+ if not trig:
+ continue
+
+ zones = get_trigger_zones(trig)
+ if not zones:
+ continue
+
+ price_info = prices.get(code)
+ if not price_info:
+ continue
+ price, _, _ = price_info
+ if price == 0:
+ continue
+
+ name = d.get("name", code)
+ if code not in state:
+ state[code] = {}
+
+ # 时间预算检查:如果超时,跳过重评只做状态记录
+ _budget_low = (time.time() - start) > TIME_BUDGET
+
+ for key, label, lo, hi in zones:
+ in_zone = lo <= price <= hi
+ prev_in_zone = state[code].get(key, None)
+
+ if in_zone and prev_in_zone != True:
+ if key == "stop_loss":
+ outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
+ record_event(code, name, "stop_loss", price, str(hi))
+ # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警)
+ if _budget_low:
+ outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad")
+ if _can_push(code, "stop_loss"):
+ push_to_xmpp(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
+ else:
+ try:
+ cost = d.get("cost", 0) or 0
+ shares = d.get("shares", 0) or 0
+ current_action = d.get("action", "")
+ result = reassess_with_context(code, name, price, cost, shares, current_action)
+ if result:
+ timing_signal = result.get("timing_signal", "")
+ action = result.get("action", "")
+ if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
+ buy_lo = d.get("entry_low", 0)
+ buy_hi = d.get("entry_high", 0)
+ rr = result.get("rr_ratio", 0)
+ if _can_push(code, "stop_loss"):
+ msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}"
+ push_to_xmpp(msg)
+ outputs.append(f" 📨 止损重评→已推送Dad: {action}")
+ except Exception as e:
+ outputs.append(f" ⚠️ 止损重评失败: {e}")
+ else:
+ extra = ""
+ if "_price" in key:
+ batch_shares = trig.get(key.replace("_price", "_shares"), "")
+ action = trig.get(key.replace("_price", "_action"), "")
+ if batch_shares:
+ extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股"
+ elif key in ("take_profit_zone",):
+ act = trig.get("take_profit_action", "")
+ if act:
+ extra = f"({act})"
+ outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
+ record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
+ # 进入区间 → 立即重评并推送给Dad(时间不够则跳过重评直接推原始告警)
+ if _budget_low:
+ if _can_push(code, key):
+ push_to_xmpp(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}")
+ outputs.append(f" 📨 区间触发(超时跳过重评)→已推送Dad")
+ else:
+ try:
+ cost = d.get("cost", 0) or 0
+ shares = d.get("shares", 0) or 0
+ current_action = d.get("action", "")
+ result = reassess_with_context(code, name, price, cost, shares, current_action)
+ if result:
+ timing_signal = result.get("timing_signal", "")
+ action = result.get("action", "")
+ # 格式化区间描述(止盈区lo=0时美化显示)
+ if key == "take_profit_zone" and lo == 0:
+ zone_desc = f"止盈监控(目标{hi:.0f})"
+ else:
+ zone_desc = f"操作区间{lo}~{hi}"
+ if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
+ rr = result.get("rr_ratio", 0)
+ if _can_push(code, key):
+ msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}"
+ push_to_xmpp(msg)
+ outputs.append(f" 📨 区间触发重评→已推送Dad: {action}")
+ else:
+ reason = f"重评结果:{timing_signal},不构成操作建议"
+ outputs.append(f" 📋 本地日志(不推): {reason}")
+ except Exception as e:
+ outputs.append(f" ⚠️ 区间重评失败: {e}")
+ state[code][key] = True
+ state_updated = True
+
+ elif not in_zone and prev_in_zone == True:
+ if key != "stop_loss":
+ outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}")
+ state[code][key] = False
+ state_updated = True
+
+ # === 第三步:买入区偏离检测 + 自动重评 ===
+ reassesed_codes = []
+ # 先做急跌检测(仅持仓,自选股不推送暴跌告警)
+ holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0}
+ for d in active:
+ code = d["code"]
+ # 非持仓跳过
+ if code not in holdings_codes:
+ continue
+ name = d.get("name", code)
+ price_info = prices.get(code)
+ if not price_info:
+ continue
+ price, _, change_pct = price_info
+ if price == 0:
+ continue
+ # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发)
+ try:
+ cp = float(change_pct) if change_pct else 0
+ except:
+ cp = 0
+ if cp <= -7:
+ prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False)
+ if not prev_alert:
+ stop_loss = d.get("stop_loss", 0)
+ sl_note = f" 止损{stop_loss}" if stop_loss else ""
+ msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}"
+ push_to_xmpp(msg)
+ outputs.append(msg)
+ state.setdefault(code, {})["__sharp_decline_triggered"] = True
+ state_updated = True
+ # 立即持久化,防止后续超时导致状态丢失而重复推送
+ save_state(state)
+ elif cp > -5:
+ # 反弹后清除告警标记,下次再跌还能报
+ state.setdefault(code, {}).pop("__sharp_decline_triggered", None)
+
+ for d in active:
+ code = d["code"]
+ name = d.get("name", code)
+ price_info = prices.get(code)
+ if not price_info:
+ continue
+ price, _, _ = price_info
+ if price == 0:
+ continue
+
+ # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区
+ entry_low = d.get("entry_low", 0)
+ entry_high = d.get("entry_high", 0)
+ if not entry_low or not entry_high:
+ continue
+
+ in_buy_zone = entry_low <= price <= entry_high
+ prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None)
+
+ # 状态变化时才触发
+ if in_buy_zone and prev_in_buy_zone == False:
+ # 重新进入买入区 → 重评确认区间是否仍然有效
+ outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评")
+ do_reassess = True
+ elif not in_buy_zone and prev_in_buy_zone == True:
+ # 离开买入区 → 立即重评,更新止损/止盈/区间
+ outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评")
+ do_reassess = True
+ else:
+ do_reassess = False
+
+ if do_reassess and HAS_REASSESS:
+ try:
+ cost = d.get("cost", 0) or 0
+ shares = d.get("shares", 0) or 0
+ profit_pct = (price - cost) / cost * 100 if cost else 0
+ is_deep_loss = profit_pct < -20
+ sentiment = "neutral"
+ if d.get("tech_snapshot"):
+ if "bearish" in d["tech_snapshot"]:
+ sentiment = "bearish"
+ elif "bullish" in d["tech_snapshot"]:
+ sentiment = "bullish"
+
+ # 调用技术面驱动重评(非机械百分比)
+ result = reassess_strategy(
+ code, name, price, cost, shares,
+ current_action=d.get("action", ""),
+ volume_signal="中性", sentiment=sentiment,
+ )
+ outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}")
+ reassesed_codes.append(code)
+ except Exception as e:
+ outputs.append(f" ⚠️ 重评失败: {e}")
+
+ # 更新买入区状态
+ if "__buy_zone" not in state.get(code, {}):
+ if code not in state:
+ state[code] = {}
+ state[code]["__buy_zone"] = in_buy_zone
+ state_updated = True
+
+ # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃)
+ if reassesed_codes and HAS_REASSESS:
+ # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ──
+ _regen_marker = "/tmp/price_monitor_regen_at"
+ _skip_regen = False
+ try:
+ if os.path.exists(_regen_marker):
+ with open(_regen_marker) as _f:
+ _last_regen = float(_f.read().strip())
+ if time.time() - _last_regen < 300:
+ _skip_regen = True
+ except:
+ pass
+
+ if _skip_regen:
+ outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑")
+ else:
+ try:
+ from strategy_lifecycle import regenerate_all
+ r = regenerate_all(stdout=False)
+ outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功")
+ outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}")
+ try:
+ with open(_regen_marker, "w") as _f:
+ _f.write(str(time.time()))
+ except:
+ pass
+ except Exception as e:
+ outputs.append(f" ⚠️ 全量重评失败: {e}")
+
+ # === 第四步:输出 ===
+ now_str = datetime.now().strftime("%H:%M:%S")
+ elapsed = time.time() - start
+
+ if outputs:
+ print(f"\n🔔 {now_str}{label}")
+ for o in outputs:
+ print(o)
+ print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}")
+ else:
+ # 无触发时 SILENT(中继不推送)
+ print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s")
+
+ if state_updated:
+ save_state(state)
+
+ # 输出耗时
+ print(f"⏱{label} {elapsed:.1f}s", flush=True)
+
+ # 清理进程锁
+ try:
+ os.remove("/tmp/price_monitor.lock")
+ except Exception:
+ pass
+
+
+def main():
+ """每cron触发跑一轮"""
+ run_once()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mo_config.py b/mo_config.py
index ca825d21..10c9ba6e 100644
--- a/mo_config.py
+++ b/mo_config.py
@@ -10,7 +10,7 @@ mo_config.py — MoFin 统一配置管理(单例模式)
用法:
from mo_config import config
- config.data_dir / "somedata.json"
+ from mo_data import read_portfolio; data = read_portfolio()
"""
import os
@@ -28,7 +28,7 @@ class MoConfig:
# 项目根目录
project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve())
- # 数据目录(SQLite 为主)
+ # 数据目录(mofin.db 等,所有数据只从 DB 读写)
data_dir: Path = field(default_factory=lambda: Path(
os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data")
))
@@ -46,24 +46,25 @@ class MoConfig:
@property
def portfolio_path(self) -> Path:
- """⚠️ 已废弃!数据在 mofin.db holdings + portfolio_summary 表。"""
- return self.data_dir / "portfolio.json"
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db holdings + portfolio_summary 表。"""
+ import warnings
+ warnings.warn("portfolio_path is deprecated — use mo_data.read_portfolio() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
@property
def decisions_path(self) -> Path:
- """⚠️ 已废弃!数据在 mofin.db holding_strategies 表。"""
- return self.data_dir / "decisions.json"
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db holding_strategies 表。"""
+ import warnings
+ warnings.warn("decisions_path is deprecated — use mo_data.read_decisions() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
@property
def watchlist_path(self) -> Path:
- """⚠️ 已废弃!数据在 mofin.db watchlist_stocks 表。"""
- return self.data_dir / "watchlist.json"
+ """⚠️ DEPRECATED: 数据已迁至 mofin.db watchlist_stocks 表。"""
+ import warnings
+ warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2)
+ return Path()
- @property
- def price_events_path(self) -> Path:
- """⚠️ 已废弃!数据在 mofin.db price_events 表。"""
- return self.data_dir / "price_events.json"
-
@property
def live_prices_path(self) -> Path:
"""⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
@@ -148,9 +149,11 @@ class MoConfig:
if not self.data_dir.exists():
issues.append(f"数据目录不存在: {self.data_dir}")
- # 检查 DB 文件(数据源)
- if not self._get_db_path().exists():
- issues.append(f"mofin.db 数据库不存在: {self._get_db_path()}")
+ if not self.portfolio_path.exists():
+ issues.append(f"portfolio_path 不存在(已废弃): {self.portfolio_path}")
+
+ if not self.decisions_path.exists():
+ issues.append(f"decisions_path 不存在(已废弃): {self.decisions_path}")
return issues
@@ -208,8 +211,8 @@ def ensure_dirs():
get_config().ensure_dirs()
-# ── 向后兼容:导出常用路径常量 ──────────────────────────────────────
-# 让旧代码可以通过熟悉的变量名访问路径
+# ── 向后兼容:导出已废弃的路由常量 ──────────────────────────────────
+# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。
def _lazy(attr):
"""懒加载属性,首次访问时从 config 获取"""
diff --git a/price_monitor.py b/price_monitor.py
index 25765e67..f28e7896 100644
--- a/price_monitor.py
+++ b/price_monitor.py
@@ -3,29 +3,21 @@
规则:进入区间报一次,离开区间报一次,中间不重复。
每次运行时一次性刷新所有持仓+自选股的实时价。
"""
-import json
import urllib.request
-import os
-import sys
-import time
+import os, sys, time, json
import sqlite3
from datetime import datetime
-# ⚠️ 以下常量已废弃:数据在 mofin.db 的 holding_strategies / holdings / watchlist_stocks 表
-# 保留仅防止 import 报错,新代码勿用
-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"
+from mo_data import read_decisions
+
BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
-STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json")
-EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
+STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json"
# DB 模块(同步实时价到 mofin.db)
sys.path.insert(0, "/home/hmo/MoFin")
try:
from mofin_db import get_conn, DB_PATH
from mo_models import calc_total_mv, calc_total_assets
- from mo_data import read_decisions
HAS_DB = True
except ImportError:
HAS_DB = False
@@ -33,13 +25,32 @@ except ImportError:
# 策略重评依赖(技术面驱动,非机械百分比)
sys.path.insert(0, "/home/hmo/web-dashboard")
try:
- from strategy_lifecycle import reassess_strategy
+ from strategy_lifecycle import reassess_strategy, reassess_with_context
HAS_REASSESS = True
except ImportError:
HAS_REASSESS = False
UA = "Mozilla/5.0"
+# ── XMPP推送 ──────────────────────────────────────────────────────────
+XMPP_USER = "hmo@yoin.fun"
+XMPP_BRIDGE = "http://127.0.0.1:5805/"
+
+def push_to_xmpp(text):
+ """通过知微 HTTP bridge 推送到Dad私信"""
+ if not text.strip():
+ return
+ try:
+ payload = json.dumps({
+ "to": XMPP_USER,
+ "body": text.strip(),
+ "type": "chat",
+ }).encode("utf-8")
+ req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"})
+ urllib.request.urlopen(req, timeout=5)
+ except Exception as e:
+ print(f"[XMPP推送失败] {e}", file=sys.stderr)
+
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
def fetch_all_prices(codes):
@@ -113,6 +124,8 @@ def refresh_data_prices():
all_codes.add(r['code'])
for r in conn.execute("SELECT code FROM watchlist_stocks"):
all_codes.add(r['code'])
+ for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"):
+ all_codes.add(r['code'])
conn.close()
except Exception as e:
print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr)
@@ -185,20 +198,11 @@ def refresh_data_prices():
# ── 写 portfolio_summary ──
mv = calc_total_mv(db_holdings)
- # 从cash_log读取最新verified现金(Dad确认的才是权威),不读portfolio_summary
- latest = conn.execute(
- 'SELECT cash_after, frozen_after FROM cash_log '
- 'WHERE verified=1 ORDER BY id DESC LIMIT 1'
+ existing = conn.execute(
+ 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1'
).fetchone()
- if latest:
- db_cash = latest['cash_after'] or 0.0
- db_frozen = latest['frozen_after'] or 0.0
- else:
- existing = conn.execute(
- 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1'
- ).fetchone()
- db_cash = existing['cash'] if existing else 0.0
- db_frozen = existing['frozen_cash'] if existing else 0.0
+ db_cash = existing['cash'] if existing else 0.0
+ db_frozen = existing['frozen_cash'] if existing else 0.0
assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen})
position_pct = round(mv / assets * 100, 2) if assets > 0 else 0
conn.execute("""
@@ -227,6 +231,16 @@ def refresh_data_prices():
"VALUES (?,?,?,datetime('now','localtime'))",
(code, p, cp)
)
+ # 补充策略股/自选股的价格(不在holdings中的)
+ for code, pdata in prices.items():
+ if code not in {h.get('code') for h in db_holdings}:
+ price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0)
+ cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0)
+ conn.execute(
+ "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
+ "VALUES (?,?,?,datetime('now','localtime'))",
+ (code, price_val, cp_val)
+ )
conn.commit()
conn.close()
@@ -263,7 +277,9 @@ def refresh_data_prices():
print(f"⚠️ DB同步异常: {e}", file=sys.stderr)
break
else:
+ # for-else: loop exhausted without break
print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr)
+ # 尝试紧急 WAL checkpoint(释放死锁)
try:
c = sqlite3.connect(str(DB_PATH), timeout=1)
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
@@ -302,37 +318,28 @@ def save_breaches(data):
json.dump(data, f, ensure_ascii=False, indent=2)
-def load_events():
- try:
- with open(EVENTS_PATH) as f:
- return json.load(f)
- except:
- return {"events": []}
-
-
-def save_events(events):
- os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True)
- with open(EVENTS_PATH, 'w') as f:
- json.dump(events, f, ensure_ascii=False, indent=2)
-
-
def record_event(code, name, event_type, price, trigger_value, event_label=""):
- """记录一次价格触发事件到 price_events.json"""
- events = load_events()
+ """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。
+
+ price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股)
+ 先注册再写事件,否则 FK 失败事件丢失。
+ """
now = datetime.now().isoformat()
- events["events"].append({
- "code": code,
- "name": name,
- "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone
- "price": round(price, 2),
- "trigger_value": trigger_value,
- "event_label": event_label,
- "timestamp": now,
- "date": datetime.now().strftime("%Y-%m-%d"),
- })
- # 保留最近10000条
- events["events"] = events["events"][-10000:]
- save_events(events)
+
+ if HAS_DB:
+ try:
+ from mofin_db import get_conn, write_price_event
+ _c = get_conn()
+ _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A"))
+ _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)",
+ (str(code), name or str(code), _exch, _typ, now))
+ _c.commit()
+ write_price_event(_c, code=code, name=name, event_type=event_type,
+ price=round(price, 2), trigger_value=str(trigger_value),
+ event_label=event_label)
+ _c.close()
+ except Exception as e:
+ print(f"[price_events DB写入失败] {e}", file=sys.stderr)
def get_trigger_zones(trigger):
@@ -368,25 +375,80 @@ def get_trigger_zones(trigger):
return zones
+def _cleanup_lock():
+ """清理进程锁文件"""
+ try:
+ os.remove("/tmp/price_monitor.lock")
+ except Exception:
+ pass
+
+def _handle_sigterm(signum, frame):
+ """收到SIGTERM时清理锁文件后退出"""
+ _cleanup_lock()
+ sys.exit(0)
+
def run_once(round_label=""):
"""执行一轮完整的监控流程"""
+ import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖
+ signal.signal(signal.SIGTERM, _handle_sigterm)
+ os.nice(10) # 降低优先级,避免与DB其他写操作抢占
+ # ── 进程锁:同一时间只跑一个实例 ──
+ _lk = "/tmp/price_monitor.lock"
+ _pid = None
+ try:
+ with open(_lk) as _f:
+ _pid = int(_f.read().strip())
+ os.kill(_pid, 0)
+ print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True)
+ return
+ except (FileNotFoundError, ProcessLookupError, ValueError):
+ pass
+ with open(_lk, "w") as _f:
+ _f.write(str(os.getpid()))
+
label = f" [{round_label}]" if round_label else ""
start = time.time()
+ TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑
# === 第一步:一次性刷新所有价格 ===
refreshed = refresh_data_prices()
- # === 第二步:检查触发条件(纯DB,不读JSON) ===
+ # === 第二步:检查触发条件 ===
try:
dec = read_decisions()
- except Exception as e:
- print(f"❌{label} 无法从DB读取决策数据: {e}", file=sys.stderr)
+ except:
+ print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr)
return
active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
state = load_state()
outputs = []
state_updated = False
+ # 时间冷却:同股同区间30分钟内不重复推
+ _push_cooldown = {}
+ _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json"
+ try:
+ import os
+ if os.path.exists(_cooldown_file):
+ with open(_cooldown_file) as _f:
+ _push_cooldown = json.load(_f)
+ except Exception:
+ _push_cooldown = {}
+
+ def _can_push(code, zone_key):
+ now = time.time()
+ key = f"{code}_{zone_key}"
+ last = _push_cooldown.get(key, 0)
+ if now - last < 1800: # 30分钟
+ return False
+ _push_cooldown[key] = now
+ # 持久化写入
+ try:
+ with open(_cooldown_file, "w") as _f:
+ json.dump(_push_cooldown, _f)
+ except Exception:
+ pass
+ return True
# 收集所有需要检查的代码
check_codes = set()
@@ -411,7 +473,7 @@ def run_once(round_label=""):
price_info = prices.get(code)
if not price_info:
continue
- price, _ = price_info
+ price, _, _ = price_info
if price == 0:
continue
@@ -419,6 +481,9 @@ def run_once(round_label=""):
if code not in state:
state[code] = {}
+ # 时间预算检查:如果超时,跳过重评只做状态记录
+ _budget_low = (time.time() - start) > TIME_BUDGET
+
for key, label, lo, hi in zones:
in_zone = lo <= price <= hi
prev_in_zone = state[code].get(key, None)
@@ -427,6 +492,30 @@ def run_once(round_label=""):
if key == "stop_loss":
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
record_event(code, name, "stop_loss", price, str(hi))
+ # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警)
+ if _budget_low:
+ outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad")
+ if _can_push(code, "stop_loss"):
+ push_to_xmpp(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!")
+ else:
+ try:
+ cost = d.get("cost", 0) or 0
+ shares = d.get("shares", 0) or 0
+ current_action = d.get("action", "")
+ result = reassess_with_context(code, name, price, cost, shares, current_action)
+ if result:
+ timing_signal = result.get("timing_signal", "")
+ action = result.get("action", "")
+ if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
+ buy_lo = d.get("entry_low", 0)
+ buy_hi = d.get("entry_high", 0)
+ rr = result.get("rr_ratio", 0)
+ if _can_push(code, "stop_loss"):
+ msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}"
+ push_to_xmpp(msg)
+ outputs.append(f" 📨 止损重评→已推送Dad: {action}")
+ except Exception as e:
+ outputs.append(f" ⚠️ 止损重评失败: {e}")
else:
extra = ""
if "_price" in key:
@@ -440,6 +529,36 @@ def run_once(round_label=""):
extra = f"({act})"
outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
+ # 进入区间 → 立即重评并推送给Dad(时间不够则跳过重评直接推原始告警)
+ if _budget_low:
+ if _can_push(code, key):
+ push_to_xmpp(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}")
+ outputs.append(f" 📨 区间触发(超时跳过重评)→已推送Dad")
+ else:
+ try:
+ cost = d.get("cost", 0) or 0
+ shares = d.get("shares", 0) or 0
+ current_action = d.get("action", "")
+ result = reassess_with_context(code, name, price, cost, shares, current_action)
+ if result:
+ timing_signal = result.get("timing_signal", "")
+ action = result.get("action", "")
+ # 格式化区间描述(止盈区lo=0时美化显示)
+ if key == "take_profit_zone" and lo == 0:
+ zone_desc = f"止盈监控(目标{hi:.0f})"
+ else:
+ zone_desc = f"操作区间{lo}~{hi}"
+ if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"):
+ rr = result.get("rr_ratio", 0)
+ if _can_push(code, key):
+ msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}"
+ push_to_xmpp(msg)
+ outputs.append(f" 📨 区间触发重评→已推送Dad: {action}")
+ else:
+ reason = f"重评结果:{timing_signal},不构成操作建议"
+ outputs.append(f" 📋 本地日志(不推): {reason}")
+ except Exception as e:
+ outputs.append(f" ⚠️ 区间重评失败: {e}")
state[code][key] = True
state_updated = True
@@ -451,17 +570,52 @@ def run_once(round_label=""):
# === 第三步:买入区偏离检测 + 自动重评 ===
reassesed_codes = []
+ # 先做急跌检测(仅持仓,自选股不推送暴跌告警)
+ holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0}
+ for d in active:
+ code = d["code"]
+ # 非持仓跳过
+ if code not in holdings_codes:
+ continue
+ name = d.get("name", code)
+ price_info = prices.get(code)
+ if not price_info:
+ continue
+ price, _, change_pct = price_info
+ if price == 0:
+ continue
+ # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发)
+ try:
+ cp = float(change_pct) if change_pct else 0
+ except:
+ cp = 0
+ if cp <= -7:
+ prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False)
+ if not prev_alert:
+ stop_loss = d.get("stop_loss", 0)
+ sl_note = f" 止损{stop_loss}" if stop_loss else ""
+ msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}"
+ push_to_xmpp(msg)
+ outputs.append(msg)
+ state.setdefault(code, {})["__sharp_decline_triggered"] = True
+ state_updated = True
+ # 立即持久化,防止后续超时导致状态丢失而重复推送
+ save_state(state)
+ elif cp > -5:
+ # 反弹后清除告警标记,下次再跌还能报
+ state.setdefault(code, {}).pop("__sharp_decline_triggered", None)
+
for d in active:
code = d["code"]
name = d.get("name", code)
price_info = prices.get(code)
if not price_info:
continue
- price, _ = price_info
+ price, _, _ = price_info
if price == 0:
continue
- # 从 DB holding_strategies 读取买入区
+ # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区
entry_low = d.get("entry_low", 0)
entry_high = d.get("entry_high", 0)
if not entry_low or not entry_high:
@@ -513,17 +667,35 @@ def run_once(round_label=""):
state[code]["__buy_zone"] = in_buy_zone
state_updated = True
- # 如果有重评过的股票,更新 decisions.json
+ # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃)
if reassesed_codes and HAS_REASSESS:
+ # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ──
+ _regen_marker = "/tmp/price_monitor_regen_at"
+ _skip_regen = False
try:
- # 重新 regenerate_all 只针对受影响的股票效率太低
- # 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析)
- from strategy_lifecycle import regenerate_all
- r = regenerate_all(stdout=False)
- outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功")
- outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}")
- except Exception as e:
- outputs.append(f" ⚠️ 全量重评失败: {e}")
+ if os.path.exists(_regen_marker):
+ with open(_regen_marker) as _f:
+ _last_regen = float(_f.read().strip())
+ if time.time() - _last_regen < 300:
+ _skip_regen = True
+ except:
+ pass
+
+ if _skip_regen:
+ outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑")
+ else:
+ try:
+ from strategy_lifecycle import regenerate_all
+ r = regenerate_all(stdout=False)
+ outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功")
+ outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}")
+ try:
+ with open(_regen_marker, "w") as _f:
+ _f.write(str(time.time()))
+ except:
+ pass
+ except Exception as e:
+ outputs.append(f" ⚠️ 全量重评失败: {e}")
# === 第四步:输出 ===
now_str = datetime.now().strftime("%H:%M:%S")
@@ -544,6 +716,12 @@ def run_once(round_label=""):
# 输出耗时
print(f"⏱{label} {elapsed:.1f}s", flush=True)
+ # 清理进程锁
+ try:
+ os.remove("/tmp/price_monitor.lock")
+ except Exception:
+ pass
+
def main():
"""每cron触发跑一轮"""
diff --git a/scripts/check_3_dbs.py b/scripts/check_3_dbs.py
new file mode 100644
index 00000000..cbaf1513
--- /dev/null
+++ b/scripts/check_3_dbs.py
@@ -0,0 +1,24 @@
+import sqlite3
+
+for label, path in [('canonical', '/home/hmo/MoFin/data/mofin.db'),
+ ('profile-local', '/home/hmo/.hermes/profiles/position-analyst/scripts/data/mofin.db'),
+ ('web-dashboard', '/home/hmo/web-dashboard/data/mofin.db')]:
+ try:
+ c = sqlite3.connect(path, timeout=5)
+ tables = [r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")]
+ print(f'=== {label}: {path}')
+ for t in ['price_events', 'holdings', 'live_prices', 'holding_strategies', 'market_snapshots']:
+ if t in tables:
+ cnt = c.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
+ try:
+ col = 'created_at' if t in ('price_events', 'market_snapshots') else 'updated_at'
+ last = c.execute(f"SELECT MAX({col}) FROM {t}").fetchone()[0]
+ except Exception:
+ last = '?'
+ print(f' {t}: {cnt} rows, last={last}')
+ else:
+ print(f' {t}: MISSING')
+ c.close()
+ except Exception as e:
+ print(f'{label}: ERROR {e}')
+ print()
\ No newline at end of file
diff --git a/scripts/mo_config.py b/scripts/mo_config.py
index 5ab6896e..10c9ba6e 100644
--- a/scripts/mo_config.py
+++ b/scripts/mo_config.py
@@ -65,11 +65,6 @@ class MoConfig:
warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2)
return Path()
- @property
- def price_events_path(self) -> Path:
- """⚠️ 已废弃!数据在 mofin.db price_events 表。"""
- return self.data_dir / "price_events.json"
-
@property
def live_prices_path(self) -> Path:
"""⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
diff --git a/scripts/notify_user3.py b/scripts/notify_user3.py
new file mode 100644
index 00000000..3f17955b
--- /dev/null
+++ b/scripts/notify_user3.py
@@ -0,0 +1,25 @@
+import json, urllib.request
+
+msg = """[笑笑] 补充:price_events 已统一到 DB
+
+你问得对——"不是都全 DB 了吗",之前确实不一致:
+
+【查出的问题】
+- price_monitor 的 record_event() 只写 price_events.json,从没调过 mofin_db 的写入函数
+- 更隐蔽的是:price_events.code 有外键 → stocks(code),未注册的股票(新候选、港股)写 DB 时外键失败被静默吞掉,这就是 7/6 之后 DB 表再也没新事件的真正原因
+
+【已修复】
+1. record_event 改为双写:DB(权威)+ JSON(兼容 mo_config/strategy_feedback 等遗留读取方)
+2. 写事件前自动把未注册股票登记进 stocks 表,外键不再失败
+3. 一次性回填:JSON 里 4064 条历史事件全部灌入 DB(现共 6353 行,最新到今天 17:06)
+4. 实测:新事件 DB/JSON 双落盘 ✅
+
+以后价格事件的权威存储就是 DB 表,健康监控的 db_freshness 也是读它了。"""
+
+payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode()
+req = urllib.request.Request("http://127.0.0.1:5805/", data=payload,
+ headers={"Content-Type": "application/json"})
+try:
+ print("XMPP:", urllib.request.urlopen(req, timeout=10).read().decode()[:80])
+except Exception as e:
+ print("XMPP fail:", e)
\ No newline at end of file
diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py
index 5c1a3641..f28e7896 100644
--- a/scripts/price_monitor.py
+++ b/scripts/price_monitor.py
@@ -12,7 +12,6 @@ from mo_data import read_decisions
BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json"
-EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
# DB 模块(同步实时价到 mofin.db)
sys.path.insert(0, "/home/hmo/MoFin")
@@ -319,37 +318,28 @@ def save_breaches(data):
json.dump(data, f, ensure_ascii=False, indent=2)
-def load_events():
- try:
- with open(EVENTS_PATH) as f:
- return json.load(f)
- except:
- return {"events": []}
-
-
-def save_events(events):
- os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True)
- with open(EVENTS_PATH, 'w') as f:
- json.dump(events, f, ensure_ascii=False, indent=2)
-
-
def record_event(code, name, event_type, price, trigger_value, event_label=""):
- """记录一次价格触发事件到 price_events.json"""
- events = load_events()
+ """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。
+
+ price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股)
+ 先注册再写事件,否则 FK 失败事件丢失。
+ """
now = datetime.now().isoformat()
- events["events"].append({
- "code": code,
- "name": name,
- "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone
- "price": round(price, 2),
- "trigger_value": trigger_value,
- "event_label": event_label,
- "timestamp": now,
- "date": datetime.now().strftime("%Y-%m-%d"),
- })
- # 保留最近10000条
- events["events"] = events["events"][-10000:]
- save_events(events)
+
+ if HAS_DB:
+ try:
+ from mofin_db import get_conn, write_price_event
+ _c = get_conn()
+ _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A"))
+ _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)",
+ (str(code), name or str(code), _exch, _typ, now))
+ _c.commit()
+ write_price_event(_c, code=code, name=name, event_type=event_type,
+ price=round(price, 2), trigger_value=str(trigger_value),
+ event_label=event_label)
+ _c.close()
+ except Exception as e:
+ print(f"[price_events DB写入失败] {e}", file=sys.stderr)
def get_trigger_zones(trigger):
@@ -581,7 +571,7 @@ def run_once(round_label=""):
# === 第三步:买入区偏离检测 + 自动重评 ===
reassesed_codes = []
# 先做急跌检测(仅持仓,自选股不推送暴跌告警)
- holdings_codes = {d["code"] for d in active if d.get("shares", 0) > 0}
+ holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0}
for d in active:
code = d["code"]
# 非持仓跳过
diff --git a/scripts/sc5.py b/scripts/sc5.py
new file mode 100644
index 00000000..abb6b337
--- /dev/null
+++ b/scripts/sc5.py
@@ -0,0 +1,12 @@
+import ast, sys
+files = ['price_monitor', 'strategy_feedback', 'system_health_check', 'mo_config', 'mofin_health']
+ok = True
+for f in files:
+ p = f'/home/hmo/MoFin/deploy/profile-scripts/{f}.py'
+ try:
+ ast.parse(open(p).read())
+ print('OK', f)
+ except SyntaxError as e:
+ print('FAIL', f, e)
+ ok = False
+sys.exit(0 if ok else 1)
\ No newline at end of file
diff --git a/scripts/test_db_only.py b/scripts/test_db_only.py
new file mode 100644
index 00000000..ccabfe45
--- /dev/null
+++ b/scripts/test_db_only.py
@@ -0,0 +1,16 @@
+import sys, os
+sys.path.insert(0, '/home/hmo/.hermes/profiles/position-analyst/scripts')
+import price_monitor as pm
+pm.record_event('000850', '华茂股份', 'entry_zone', 3.98, '3.91~4.04', '加仓区间')
+print('record_event done')
+
+import sqlite3
+c = sqlite3.connect('/home/hmo/MoFin/data/mofin.db')
+r = c.execute("SELECT code,name,price,created_at FROM price_events WHERE code='000850' ORDER BY id DESC LIMIT 1").fetchone()
+print('DB:', r)
+c.execute("DELETE FROM price_events WHERE code='000850' AND created_at > '2026-07-20 17:50'")
+c.commit()
+print('cleaned test row')
+
+# JSON must NOT be recreated
+print('JSON exists?', os.path.exists('/home/hmo/web-dashboard/data/price_events.json'))
\ No newline at end of file