diff --git a/deploy/profile-scripts/batch_reassess.py b/deploy/profile-scripts/batch_reassess.py
index b201c6e8..196dab1b 100644
--- a/deploy/profile-scripts/batch_reassess.py
+++ b/deploy/profile-scripts/batch_reassess.py
@@ -1,14 +1,9 @@
#!/usr/bin/env python3
-"""batch_reassess.py — 批量补全12维(九维矩阵)LLM分析(逐只处理,间隔防限流)
+"""batch_reassess.py — 批量补全九维分析(逐只处理,间隔防限流)
-用法:
- python3 batch_reassess.py # 所有缺分析/过期的 active 策略
- python3 batch_reassess.py --type holding # 只处理持仓策略
- python3 batch_reassess.py --type watchlist # 只处理自选策略
- python3 batch_reassess.py --type holding --today # 持仓每日刷新(今早未评过的强制重评)
- python3 batch_reassess.py --code XXXXXX # 单只
+用法: python3 batch_reassess.py [--all] [--code XXXXXX]
-流程:收集最新数据 → 调LLM(gateway)写12维分析+策略 → 保存到DB
+流程:收集最新数据 → 调LLM(gateway)写九维分析+策略 → 保存到DB
"""
import sys, json, subprocess, sqlite3, re, time
from datetime import datetime
@@ -16,10 +11,9 @@ from datetime import datetime
DB = "/home/hmo/MoFin/data/mofin.db"
GATEWAY = "http://127.0.0.1:8643/v1/chat/completions"
COOLDOWN_HOURS = 1
-STALE_HOURS = 20 # 分析超过20小时视为过期,需要重评
def has_llm_analysis(code):
- """检查是否为LLM生成的12维分析(>500字)"""
+ """检查是否为LLM生成的九维分析(>500字)"""
conn = sqlite3.connect(DB)
r = conn.execute("SELECT LENGTH(full_analysis) FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone()
conn.close()
@@ -39,34 +33,6 @@ def in_cooldown(code):
except:
return False
-def analysis_stale(code, force_today=False):
- """分析是否过期(>STALE_HOURS 或 force_today 时今早4点前未重评)"""
- conn = sqlite3.connect(DB)
- r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone()
- conn.close()
- if not r or not r[0]:
- return True
- try:
- last = datetime.fromisoformat(r[0])
- if force_today:
- today4am = datetime.now().replace(hour=4, minute=0, second=0, microsecond=0)
- return last < today4am
- return (datetime.now() - last).total_seconds() / 3600 > STALE_HOURS
- except:
- return True
-
-def get_portfolio():
- """从 portfolio_summary 读实时现金/总资产(不再硬编码)"""
- try:
- conn = sqlite3.connect(DB)
- r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone()
- conn.close()
- if r and r[1]:
- return int(r[0] or 0), int(r[1])
- except Exception:
- pass
- return 0, 0
-
def collect_data(code):
"""收集最新数据"""
data = {"code": code}
@@ -89,14 +55,7 @@ def collect_data(code):
conn.close()
# 从腾讯API拉最新价和基本面
- # 代码前缀:5位=港股(hk),6/9开头=沪(sh),其他=深(sz)
- _c = str(code)
- if len(_c) == 5:
- prefix = "hk"
- elif _c.startswith(("6", "9")):
- prefix = "sh"
- else:
- prefix = "sz"
+ prefix = "sh" if str(code).startswith(("6","9")) else "sz"
try:
r = subprocess.run(["curl", "-s", f"http://qt.gtimg.cn/q={prefix}{code}"], capture_output=True, timeout=10)
parts = r.stdout.decode("gbk", errors="ignore").split("~")
@@ -122,9 +81,8 @@ def collect_data(code):
def build_prompt(data):
"""构建LLM prompt,要求输出完整策略"""
- cash, total = get_portfolio() # 实时从 portfolio_summary 读
- if not total:
- cash, total = 241330, 929727 # 兜底(DB读不到时)
+ cash = 321271 # 可用现金(从DB读取)
+ total = 952879 # 总资产
# 拉取资金流数据
_flow_note = "暂无资金流数据"
@@ -313,19 +271,18 @@ def save_result(code, full_text, parsed):
conn.close()
-def process_stock(code, force_today=False):
+def process_stock(code):
"""处理单只股票"""
print(f"\n{'='*50}")
print(f"处理: {code}")
print(f"{'='*50}")
- if in_cooldown(code):
- print(f" ⏭ 冷却期内,跳过")
+ if has_llm_analysis(code):
+ print(f" ⏭ 已有LLM九维分析,跳过")
return False
- # 有分析且未过期 → 跳过(除非 force_today 且今早未评)
- if has_llm_analysis(code) and not analysis_stale(code, force_today):
- print(f" ⏭ 已有12维分析且未过期,跳过")
+ if in_cooldown(code):
+ print(f" ⏭ 冷却期内,跳过")
return False
print(f" 收集数据...", flush=True)
@@ -372,41 +329,29 @@ def process_stock(code, force_today=False):
def main():
codes = []
- force_today = "--today" in sys.argv
- dtype = None
- if "--type" in sys.argv:
- idx = sys.argv.index("--type")
- dtype = sys.argv[idx + 1] # holding | watchlist | all
if "--code" in sys.argv:
idx = sys.argv.index("--code")
codes = [sys.argv[idx+1]]
else:
- # 按类型筛选 active 策略
- type_map = {"holding": "持仓策略", "watchlist": "自选策略"}
+ # 所有自选策略
conn = sqlite3.connect(DB)
- if dtype in type_map:
- rows = conn.execute(
- "SELECT code FROM holding_strategies WHERE status='active' AND decision_type=? ORDER BY code",
- (type_map[dtype],)).fetchall()
- else:
- rows = conn.execute(
- "SELECT code FROM holding_strategies WHERE status='active' ORDER BY decision_type, code").fetchall()
+ rows = conn.execute("SELECT code FROM holding_strategies WHERE status='active' AND decision_type='自选策略' ORDER BY code").fetchall()
conn.close()
codes = [r[0] for r in rows]
- print(f"待处理: {len(codes)}只 (type={dtype or 'all'}, force_today={force_today})")
+ print(f"待处理: {len(codes)}只")
ok = 0
fail = 0
skip = 0
for i, code in enumerate(codes):
- if has_llm_analysis(code) and not analysis_stale(code, force_today):
- print(f" [{i+1}/{len(codes)}] ⏭ {code} 已有12维分析且未过期")
+ if has_llm_analysis(code):
+ print(f" [{i+1}/{len(codes)}] ⏭ {code} 已有LLM分析")
skip += 1
continue
print(f" [{i+1}/{len(codes)}] ", end="", flush=True)
- if process_stock(code, force_today):
+ if process_stock(code):
ok += 1
else:
fail += 1
diff --git a/deploy/profile-scripts/candidate_filter.py b/deploy/profile-scripts/candidate_filter.py
index 4a48c068..e33ab5b3 100644
--- a/deploy/profile-scripts/candidate_filter.py
+++ b/deploy/profile-scripts/candidate_filter.py
@@ -17,9 +17,7 @@ DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
UA = "Mozilla/5.0"
def get_conn():
- c = sqlite3.connect(str(DB_PATH), timeout=30)
- c.execute("PRAGMA busy_timeout=30000")
- return c
+ return sqlite3.connect(str(DB_PATH))
def log_candidate(conn, code, stage, passed, detail):
"""记录过滤日志"""
diff --git a/deploy/profile-scripts/market_insight.py b/deploy/profile-scripts/market_insight.py
index 1b65513b..2bb438c3 100644
--- a/deploy/profile-scripts/market_insight.py
+++ b/deploy/profile-scripts/market_insight.py
@@ -1,203 +1,203 @@
-#!/usr/bin/env python3
-"""market_insight.py — 基于 market.json 数据生成基础洞察 + 潜力挖掘
-
-输出:更新 data/market.json 中的 insights / potential_stocks 字段
-
-策略:
- 1. 行业热点 vs 持仓匹配 → 相关影响
- 2. 资金流向异常 → 关注信号
- 3. 市场情绪 → 每日研判
- 4. 潜力挖掘 → 强势行业中寻找持仓相关标的
-"""
-
-import json
-import sys
-from datetime import datetime
-from pathlib import Path
-
-DATA_DIR = Path(__file__).parent.parent / "data"
-
-# ── 持仓股 → 行业映射(从 stock_profiles 自动提取) ──
-
-def load_holding_industry_map():
- """从 stock_profiles 和 portfolio 提取持仓→行业映射"""
- try:
- with open(DATA_DIR / "stock_profiles.json", "r", encoding="utf-8") as f:
- profiles = json.load(f).get("profiles", [])
-
- # 优先从DB读取持仓(Dad铁律:禁用JSON直读)
- from mo_data import read_portfolio
- portfolio = read_portfolio()
- except FileNotFoundError:
- return {}
-
- # 构建 code→name 映射(从 portfolio)
- code_to_name = {}
- for item in portfolio.get("holdings", []):
- code_to_name[item.get("code", "")] = item.get("name", "")
-
- # 构建行业→持仓列表
- industry_holdings = {}
- for p in profiles:
- code = p.get("code", "")
- name = p.get("name", "")
- sector = p.get("sector", "")
- if not sector or sector == "待补全":
- continue
- # 提取一级行业(取斜杠前第一个)
- primary = sector.split("/")[0].split("(")[0].strip()
- if primary:
- industry_holdings.setdefault(primary, []).append({
- "code": code,
- "name": name,
- "sector": sector,
- })
- return industry_holdings
-
-
-def generate():
- # market_path 在DB和fallback两个分支后都会用到,所以提前定义
- market_path = DATA_DIR / "market.json"
- # 优先从 SQLite 读取市场数据
- try:
- from mofin_db import get_conn, query_latest_market
- conn = get_conn()
- market = query_latest_market(conn)
- conn.close()
- if market and market.get("sectors"):
- sectors = market["sectors"]
- top_gainers = market.get("top_gainers", [])
- top_losers = market.get("top_losers", [])
- mood = market.get("mood", "unknown")
- up_ratio = market.get("up_ratio", 0)
- timestamp = market.get("timestamp", "")
- # 字段名适配
- for s in sectors:
- s["change"] = s.get("change_pct", 0)
- for g in top_gainers:
- g["change"] = g.get("change_pct", 0)
- for l in top_losers:
- l["change"] = l.get("change_pct", 0)
- else:
- raise Exception("no data")
- except Exception:
- market_path = DATA_DIR / "market.json"
- with open(market_path, "r", encoding="utf-8") as f:
- market = json.load(f)
- sectors = market.get("sectors", [])
- top_gainers = market.get("top_gainers", [])
- top_losers = market.get("top_losers", [])
- mood = market.get("mood", "unknown")
- up_ratio = market.get("up_ratio", 0)
- timestamp = market.get("timestamp", "")
-
- industry_holdings = load_holding_industry_map()
- insights = []
- potentials = []
-
- # ── 洞察1:市场情绪总览 ──
- mood_cn = {"bullish": "偏强", "neutral": "中性", "bearish": "偏弱", "unknown": "未知"}
- insights.append(
- f"市场情绪{mood_cn.get(mood, '未知')},上涨占比{up_ratio}%"
- )
-
- # ── 洞察2:领涨行业 vs 持仓影响 ──
- gainer_insights = []
- for g in top_gainers[:3]:
- name = g.get("name", "")
- change = g.get("change", 0)
- # 看持仓中是否有该行业
- matched = []
- for industry, holdings in industry_holdings.items():
- if industry in name or name in industry:
- matched.extend([h["name"] for h in holdings])
- if matched:
- gainer_insights.append(
- f"{name}+{change}%, 关联持仓{'/'.join(matched[:3])}受益"
- )
- else:
- gainer_insights.append(f"{name}+{change}%, 暂无持仓")
- if gainer_insights:
- insights.append("领涨板块: " + " | ".join(gainer_insights[:2]))
-
- # ── 洞察3:领跌行业 vs 持仓风险 ──
- loser_insights = []
- for g in top_losers[:3]:
- name = g.get("name", "")
- change = g.get("change", 0)
- matched = []
- for industry, holdings in industry_holdings.items():
- if industry in name or name in industry:
- matched.extend([h["name"] for h in holdings])
- if matched:
- loser_insights.append(
- f"{name}{change}%, {'/'.join(matched[:2])}需关注"
- )
- else:
- loser_insights.append(f"{name}{change}%")
- if loser_insights:
- insights.append("风险板块: " + " | ".join(loser_insights[:3]))
-
- # ── 洞察4:资金流向异动 ──
- big_inflow = [s for s in sectors if (s.get("net_inflow") or 0) > 50]
- big_outflow = [s for s in sectors if (s.get("net_inflow") or 0) < -50]
- if big_inflow:
- top = max(big_inflow, key=lambda s: s["net_inflow"])
- insights.append(
- f"资金流入最大: {top['name']} {top['net_inflow']}亿"
- )
- if big_outflow:
- top = min(big_outflow, key=lambda s: s["net_inflow"])
- insights.append(
- f"资金流出最大: {top['name']} {top['net_inflow']}亿"
- )
-
- # ── 潜力股挖掘:从强势行业中找持仓或自选相关 ──
- for g in top_gainers[:5]:
- name = g.get("name", "")
- change = g.get("change", 0)
- if change < 2:
- continue # 只关注涨>2%的
-
- # 找该行业指数有没有关联持仓
- lead_stock = g.get("lead_stock", "")
- if lead_stock:
- potentials.append({
- "name": lead_stock,
- "reason": f"{name}领涨股, 板块+{change}%",
- })
-
- # 看持仓中是否有该行业
- for industry, holdings in industry_holdings.items():
- if industry in name or name in industry:
- for h in holdings:
- potentials.append({
- "name": h["name"],
- "reason": f"所在行业{name}涨{change}%",
- })
-
- # 去重(最多5条)
- seen = set()
- unique_potentials = []
- for p in potentials:
- key = p["name"]
- if key not in seen:
- seen.add(key)
- unique_potentials.append(p)
- if len(unique_potentials) >= 5:
- break
- potentials = unique_potentials
-
- # ── 写入 market.json ──
- market["insights"] = insights
- market["potential_stocks"] = potentials
- market["insight_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
-
- with open(market_path, "w", encoding="utf-8") as f:
- json.dump(market, f, ensure_ascii=False, indent=2)
-
- print(f"生成{len(insights)}条洞察 + {len(potentials)}条潜力挖掘")
-
-
-if __name__ == "__main__":
- generate()
+#!/usr/bin/env python3
+"""market_insight.py — 基于 market.json 数据生成基础洞察 + 潜力挖掘
+
+输出:更新 data/market.json 中的 insights / potential_stocks 字段
+
+策略:
+ 1. 行业热点 vs 持仓匹配 → 相关影响
+ 2. 资金流向异常 → 关注信号
+ 3. 市场情绪 → 每日研判
+ 4. 潜力挖掘 → 强势行业中寻找持仓相关标的
+"""
+
+import json
+import sys
+from datetime import datetime
+from pathlib import Path
+
+DATA_DIR = Path(__file__).parent.parent / "data"
+
+# ── 持仓股 → 行业映射(从 stock_profiles 自动提取) ──
+
+def load_holding_industry_map():
+ """从 stock_profiles 和 portfolio 提取持仓→行业映射"""
+ try:
+ with open(DATA_DIR / "stock_profiles.json", "r", encoding="utf-8") as f:
+ profiles = json.load(f).get("profiles", [])
+
+ # 优先从DB读取持仓(Dad铁律:禁用JSON直读)
+ from mo_data import read_portfolio
+ portfolio = read_portfolio()
+ except FileNotFoundError:
+ return {}
+
+ # 构建 code→name 映射(从 portfolio)
+ code_to_name = {}
+ for item in portfolio.get("holdings", []):
+ code_to_name[item.get("code", "")] = item.get("name", "")
+
+ # 构建行业→持仓列表
+ industry_holdings = {}
+ for p in profiles:
+ code = p.get("code", "")
+ name = p.get("name", "")
+ sector = p.get("sector", "")
+ if not sector or sector == "待补全":
+ continue
+ # 提取一级行业(取斜杠前第一个)
+ primary = sector.split("/")[0].split("(")[0].strip()
+ if primary:
+ industry_holdings.setdefault(primary, []).append({
+ "code": code,
+ "name": name,
+ "sector": sector,
+ })
+ return industry_holdings
+
+
+def generate():
+ # market_path 在DB和fallback两个分支后都会用到,所以提前定义
+ market_path = DATA_DIR / "market.json"
+ # 优先从 SQLite 读取市场数据
+ try:
+ from mofin_db import get_conn, query_latest_market
+ conn = get_conn()
+ market = query_latest_market(conn)
+ conn.close()
+ if market and market.get("sectors"):
+ sectors = market["sectors"]
+ top_gainers = market.get("top_gainers", [])
+ top_losers = market.get("top_losers", [])
+ mood = market.get("mood", "unknown")
+ up_ratio = market.get("up_ratio", 0)
+ timestamp = market.get("timestamp", "")
+ # 字段名适配
+ for s in sectors:
+ s["change"] = s.get("change_pct", 0)
+ for g in top_gainers:
+ g["change"] = g.get("change_pct", 0)
+ for l in top_losers:
+ l["change"] = l.get("change_pct", 0)
+ else:
+ raise Exception("no data")
+ except Exception:
+ market_path = DATA_DIR / "market.json"
+ with open(market_path, "r", encoding="utf-8") as f:
+ market = json.load(f)
+ sectors = market.get("sectors", [])
+ top_gainers = market.get("top_gainers", [])
+ top_losers = market.get("top_losers", [])
+ mood = market.get("mood", "unknown")
+ up_ratio = market.get("up_ratio", 0)
+ timestamp = market.get("timestamp", "")
+
+ industry_holdings = load_holding_industry_map()
+ insights = []
+ potentials = []
+
+ # ── 洞察1:市场情绪总览 ──
+ mood_cn = {"bullish": "偏强", "neutral": "中性", "bearish": "偏弱", "unknown": "未知"}
+ insights.append(
+ f"市场情绪{mood_cn.get(mood, '未知')},上涨占比{up_ratio}%"
+ )
+
+ # ── 洞察2:领涨行业 vs 持仓影响 ──
+ gainer_insights = []
+ for g in top_gainers[:3]:
+ name = g.get("name", "")
+ change = g.get("change", 0)
+ # 看持仓中是否有该行业
+ matched = []
+ for industry, holdings in industry_holdings.items():
+ if industry in name or name in industry:
+ matched.extend([h["name"] for h in holdings])
+ if matched:
+ gainer_insights.append(
+ f"{name}+{change}%, 关联持仓{'/'.join(matched[:3])}受益"
+ )
+ else:
+ gainer_insights.append(f"{name}+{change}%, 暂无持仓")
+ if gainer_insights:
+ insights.append("领涨板块: " + " | ".join(gainer_insights[:2]))
+
+ # ── 洞察3:领跌行业 vs 持仓风险 ──
+ loser_insights = []
+ for g in top_losers[:3]:
+ name = g.get("name", "")
+ change = g.get("change", 0)
+ matched = []
+ for industry, holdings in industry_holdings.items():
+ if industry in name or name in industry:
+ matched.extend([h["name"] for h in holdings])
+ if matched:
+ loser_insights.append(
+ f"{name}{change}%, {'/'.join(matched[:2])}需关注"
+ )
+ else:
+ loser_insights.append(f"{name}{change}%")
+ if loser_insights:
+ insights.append("风险板块: " + " | ".join(loser_insights[:3]))
+
+ # ── 洞察4:资金流向异动 ──
+ big_inflow = [s for s in sectors if s.get("net_inflow", 0) > 50]
+ big_outflow = [s for s in sectors if s.get("net_inflow", 0) < -50]
+ if big_inflow:
+ top = max(big_inflow, key=lambda s: s["net_inflow"])
+ insights.append(
+ f"资金流入最大: {top['name']} {top['net_inflow']}亿"
+ )
+ if big_outflow:
+ top = min(big_outflow, key=lambda s: s["net_inflow"])
+ insights.append(
+ f"资金流出最大: {top['name']} {top['net_inflow']}亿"
+ )
+
+ # ── 潜力股挖掘:从强势行业中找持仓或自选相关 ──
+ for g in top_gainers[:5]:
+ name = g.get("name", "")
+ change = g.get("change", 0)
+ if change < 2:
+ continue # 只关注涨>2%的
+
+ # 找该行业指数有没有关联持仓
+ lead_stock = g.get("lead_stock", "")
+ if lead_stock:
+ potentials.append({
+ "name": lead_stock,
+ "reason": f"{name}领涨股, 板块+{change}%",
+ })
+
+ # 看持仓中是否有该行业
+ for industry, holdings in industry_holdings.items():
+ if industry in name or name in industry:
+ for h in holdings:
+ potentials.append({
+ "name": h["name"],
+ "reason": f"所在行业{name}涨{change}%",
+ })
+
+ # 去重(最多5条)
+ seen = set()
+ unique_potentials = []
+ for p in potentials:
+ key = p["name"]
+ if key not in seen:
+ seen.add(key)
+ unique_potentials.append(p)
+ if len(unique_potentials) >= 5:
+ break
+ potentials = unique_potentials
+
+ # ── 写入 market.json ──
+ market["insights"] = insights
+ market["potential_stocks"] = potentials
+ market["insight_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
+
+ with open(market_path, "w", encoding="utf-8") as f:
+ json.dump(market, f, ensure_ascii=False, indent=2)
+
+ print(f"生成{len(insights)}条洞察 + {len(potentials)}条潜力挖掘")
+
+
+if __name__ == "__main__":
+ generate()
diff --git a/deploy/profile-scripts/mofin_health.py b/deploy/profile-scripts/mofin_health.py
index d4ba0a8b..bc9198f8 100644
--- a/deploy/profile-scripts/mofin_health.py
+++ b/deploy/profile-scripts/mofin_health.py
@@ -1,999 +1,925 @@
-#!/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
-
- # price_events 特殊处理:活跃存储是 price_events.json(price_monitor 实时写入),
- # DB 表是旧遗留。读 JSON 最后一条事件的时间。
- try:
- _pe_path = Path("/home/hmo/web-dashboard/data/price_events.json")
- if _pe_path.exists():
- _pe = json.loads(_pe_path.read_text(encoding="utf-8"))
- _items = _pe if isinstance(_pe, list) else _pe.get("events", [])
- if _items:
- _last = _items[-1]
- _ts = _last.get("timestamp") or _last.get("created_at") or ""
- _dt = datetime.fromisoformat(str(_ts).replace("Z", ""))
- _age = (now - _dt).total_seconds() / 3600
- # 替换 db_freshness 里 price_events 那条(DB 旧数据)
- db_freshness = [f for f in db_freshness if f["table"] != "price_events"]
- db_freshness.append({
- "table": "price_events.json", "label": "价格事件",
- "last_record": _dt.strftime("%m-%d %H:%M"),
- "age_hours": round(_age, 1),
- "warn": _age > 24,
- })
- 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文件
+ 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
+ json_entities.append({
+ "name": jf.name,
+ "desc": JSON_DESC.get(jf.name, ""),
+ "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"),
+ })
+
+ # ── 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,
+ }
+ 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/premarket_full_review.py b/deploy/profile-scripts/premarket_full_review.py
index a8c1060b..1cebcd1c 100644
--- a/deploy/profile-scripts/premarket_full_review.py
+++ b/deploy/profile-scripts/premarket_full_review.py
@@ -1,59 +1,40 @@
-#!/usr/bin/env python3
-"""premarket_full_review.py — 盘前全量重评
-
-执行顺序:
-1. regenerate_all() 全量技术参数重评(持仓+自选)
-2. batch_reassess.py --type holding --today 持仓12维LLM分析(每日强制刷新)
-3. watchlist_auto_exit() 自选退出检查
-4. 输出摘要
-
-调度:交易日 08:10(A股09:30开盘)
-"""
-import sys, os, json
-sys.path.insert(0, '/home/hmo/MoFin')
-
-# Step 1: 全量技术参数重评
-print("=" * 50)
-print("📊 盘前全量重评开始")
-print("=" * 50)
-from strategy_lifecycle import regenerate_all
-result = regenerate_all(stdout=True)
-print(f"\n重评完成: {result.get('ok',0)}/{result.get('total',0)}成功")
-
-# Step 1.5: 持仓 12 维 LLM 深度分析——后台分离执行(12-40分钟,不能阻塞 cron 的 120s 超时)
-print("\n" + "=" * 50)
-print("🧠 持仓12维LLM分析(后台分离启动)")
-print("=" * 50)
-import subprocess as _sp
-analysis_result = {"mode": "detached"}
-try:
- _log = open("/tmp/holdings_12d_daily.log", "a")
- _sp.Popen(
- ["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/batch_reassess.py",
- "--type", "holding", "--today"],
- stdout=_log, stderr=_log, start_new_session=True)
- print(" ✅ 12维分析已后台启动,日志: /tmp/holdings_12d_daily.log(结果落DB,不阻塞盘前流程)")
-except Exception as e:
- print(f" ⚠️ 12维分析启动失败: {e}")
- analysis_result = {"mode": "detached", "error": str(e)[:100]}
-
-# Step 2: 自选退出
-print("\n" + "=" * 50)
-print("🔍 自选退出检查")
-print("=" * 50)
-from scripts.watchlist_auto_exit import main as auto_exit
-exited = auto_exit(dry_run=False)
-
-# Step 3: 写入摘要供开盘简报引用
-summary = {
- "premarket_at": __import__('datetime').datetime.now().isoformat(),
- "reassess": result,
- "llm_analysis_12d": analysis_result,
- "auto_exit": [{"code": c, "name": n, "reason": r} for c, n, s, r in exited],
- "total_kept": result.get('total', 0) - len(exited),
-}
-os.makedirs("/tmp/mofin_premarket", exist_ok=True)
-with open("/tmp/mofin_premarket/summary.json", "w") as f:
- json.dump(summary, f, ensure_ascii=False, indent=2)
-
-print(f"\n✅ 盘前重评完毕")
+#!/usr/bin/env python3
+"""premarket_full_review.py — 盘前全量重评
+
+执行顺序:
+1. regenerate_all() 全量技术分析重评(持仓+自选)
+2. watchlist_auto_exit() 自选退出检查
+3. 输出摘要
+
+调度:交易日 08:10(A股09:30开盘)
+"""
+import sys, os, json
+sys.path.insert(0, '/home/hmo/MoFin')
+
+# Step 1: 全量重评
+print("=" * 50)
+print("📊 盘前全量重评开始")
+print("=" * 50)
+from strategy_lifecycle import regenerate_all
+result = regenerate_all(stdout=True)
+print(f"\n重评完成: {result.get('ok',0)}/{result.get('total',0)}成功")
+
+# Step 2: 自选退出
+print("\n" + "=" * 50)
+print("🔍 自选退出检查")
+print("=" * 50)
+from scripts.watchlist_auto_exit import main as auto_exit
+exited = auto_exit(dry_run=False)
+
+# Step 3: 写入摘要供开盘简报引用
+summary = {
+ "premarket_at": __import__('datetime').datetime.now().isoformat(),
+ "reassess": result,
+ "auto_exit": [{"code": c, "name": n, "reason": r} for c, n, s, r in exited],
+ "total_kept": result.get('total', 0) - len(exited),
+}
+os.makedirs("/tmp/mofin_premarket", exist_ok=True)
+with open("/tmp/mofin_premarket/summary.json", "w") as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+
+print(f"\n✅ 盘前重评完毕")
diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py
index 3532f0d7..5c1a3641 100644
--- a/deploy/profile-scripts/price_monitor.py
+++ b/deploy/profile-scripts/price_monitor.py
@@ -1,742 +1,742 @@
-#!/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"
-EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.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 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()
- 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)
-
-
-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"
+EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.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 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()
+ 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)
+
+
+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", 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/deploy/profile-scripts/promote_candidates.py b/deploy/profile-scripts/promote_candidates.py
index 67a47e9a..83718d80 100644
--- a/deploy/profile-scripts/promote_candidates.py
+++ b/deploy/profile-scripts/promote_candidates.py
@@ -1,136 +1,130 @@
-#!/usr/bin/env python3
-"""promote_candidates.py — 自动提拔候选股入自选
-
-从 candidates 表读未提拔的候选,评估后自动加入 holding_strategies。
-"""
-import sys, json, sqlite3
-from pathlib import Path
-from datetime import datetime
-
-DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
-
-def main():
- conn = sqlite3.connect(str(DB_PATH), timeout=30)
- conn.execute("PRAGMA busy_timeout=30000")
- conn.row_factory = sqlite3.Row
-
- # 读未提拔候选(按评分降序)
- rows = conn.execute("""
- SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target
- FROM candidates c
- WHERE (c.promoted IS NULL OR c.promoted = 0)
- AND (c.dropped IS NULL OR c.dropped = 0)
- AND c.score_final >= 4
- ORDER BY c.score_final DESC
- """).fetchall()
-
- if not rows:
- print("[PROMOTE] 无待提拔候选")
- conn.close()
- return
-
- promoted = 0
- for r in rows:
- code = str(r[0])
- name = r[1] or code
- score = r[2] or 0
- entry_range = r[3] or ""
- sl = r[4] or 0
- tp = r[5] or 0
-
- # 解析 entry_range
- el, eh = 0, 0
- if "~" in entry_range:
- parts = entry_range.split("~")
- try:
- el = float(parts[0])
- eh = float(parts[1])
- except: pass
-
- # 查是否已在 holding_strategies
- exists = conn.execute(
- "SELECT id FROM holding_strategies WHERE code=? AND status='active'",
- (code,)
- ).fetchone()
- if exists:
- conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
- print(f" ⏭ {code} {name} 已在自选中,标记promoted")
- continue
-
- # 验证实时价格:无有效价格的候选股不入自选(防假数据污染)
- try:
- import subprocess, json as _jj
- _r = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code],
- capture_output=True, text=True, timeout=10)
- _q = _jj.loads(_r.stdout)
- if float(_q.get("price", 0)) <= 0:
- print(f" ⏭ {code} {name} 无实时价格,跳过")
- continue
- except Exception as _e:
- print(f" ⏭ {code} {name} 价格获取失败({_e}),跳过")
- continue
-
- # 构建策略
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- timing_signal = "买入" if score >= 7 else "关注"
- price_est = (el + eh) / 2 if el > 0 and eh > 0 else 0
- reason_text = []
- if el > 0: reason_text.append(f"买{el}~{eh}")
- if sl > 0: reason_text.append(f"损{sl}")
- if tp > 0: reason_text.append(f"盈{tp}")
- if sl > 0 and tp > 0 and price_est > 0:
- rr = (tp - price_est) / (price_est - sl) if (price_est - sl) > 0 else 0
- reason_text.append(f"RR{rr:.1f}")
- reason_text.append(f"评分{score}")
- action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})"
-
- cur = conn.execute("""
- INSERT OR IGNORE INTO holding_strategies
- (code, name, price, entry_low, entry_high, stop_loss, take_profit,
- timing_signal, action, decision_type, strategy_type, status,
- rr_ratio, stock_category, created_at, updated_at,
- sector_context, quality_check)
- VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan',
- 'active',0,'关注',?,?,'', 'pending')
- """, (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now))
- newly_added = cur.rowcount > 0
-
- conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
- if newly_added:
- promoted += 1
- print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True)
- else:
- print(f" ⏭ {code} {name} 已在自选策略中,标记promoted", flush=True)
-
- # 触发全量重评(生成完整9维策略)——仅新插入的股票需要
- if newly_added:
- try:
- import subprocess as _sp
- r = _sp.run(["python3", "/home/hmo/MoFin/scripts/per_stock_reassess.py", code],
- capture_output=True, text=True, timeout=60)
- if r.returncode == 0:
- print(f" 重评完成", flush=True)
- else:
- print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True)
- except Exception as e:
- print(f" 重评异常: {e}", flush=True)
-
- conn.commit()
- print(f"\n[PROMOTE] 本次提拔{promoted}只", flush=True)
-
- # 推XMPP
- if promoted > 0:
- try:
- import urllib.request
- msg = f"📈 自动提拔{promoted}只候选入自选"
- 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"})
- urllib.request.urlopen(req, timeout=5)
- except Exception:
- pass
-
- conn.close()
-
-if __name__ == "__main__":
- main()
+#!/usr/bin/env python3
+"""promote_candidates.py — 自动提拔候选股入自选
+
+从 candidates 表读未提拔的候选,评估后自动加入 holding_strategies。
+"""
+import sys, json, sqlite3
+from pathlib import Path
+from datetime import datetime
+
+DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
+
+def main():
+ conn = sqlite3.connect(str(DB_PATH))
+ conn.row_factory = sqlite3.Row
+
+ # 读未提拔候选(按评分降序)
+ rows = conn.execute("""
+ SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target
+ FROM candidates c
+ WHERE (c.promoted IS NULL OR c.promoted = 0)
+ AND (c.dropped IS NULL OR c.dropped = 0)
+ AND c.score_final >= 4
+ ORDER BY c.score_final DESC
+ """).fetchall()
+
+ if not rows:
+ print("[PROMOTE] 无待提拔候选")
+ conn.close()
+ return
+
+ promoted = 0
+ for r in rows:
+ code = str(r[0])
+ name = r[1] or code
+ score = r[2] or 0
+ entry_range = r[3] or ""
+ sl = r[4] or 0
+ tp = r[5] or 0
+
+ # 解析 entry_range
+ el, eh = 0, 0
+ if "~" in entry_range:
+ parts = entry_range.split("~")
+ try:
+ el = float(parts[0])
+ eh = float(parts[1])
+ except: pass
+
+ # 查是否已在 holding_strategies
+ exists = conn.execute(
+ "SELECT id FROM holding_strategies WHERE code=? AND status='active'",
+ (code,)
+ ).fetchone()
+ if exists:
+ conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
+ print(f" ⏭ {code} {name} 已在自选中,标记promoted")
+ continue
+
+ # 验证实时价格:无有效价格的候选股不入自选(防假数据污染)
+ try:
+ import subprocess, json as _jj
+ _r = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code],
+ capture_output=True, text=True, timeout=10)
+ _q = _jj.loads(_r.stdout)
+ if float(_q.get("price", 0)) <= 0:
+ print(f" ⏭ {code} {name} 无实时价格,跳过")
+ continue
+ except Exception as _e:
+ print(f" ⏭ {code} {name} 价格获取失败({_e}),跳过")
+ continue
+
+ # 构建策略
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ timing_signal = "买入" if score >= 7 else "关注"
+ price_est = (el + eh) / 2 if el > 0 and eh > 0 else 0
+ reason_text = []
+ if el > 0: reason_text.append(f"买{el}~{eh}")
+ if sl > 0: reason_text.append(f"损{sl}")
+ if tp > 0: reason_text.append(f"盈{tp}")
+ if sl > 0 and tp > 0 and price_est > 0:
+ rr = (tp - price_est) / (price_est - sl) if (price_est - sl) > 0 else 0
+ reason_text.append(f"RR{rr:.1f}")
+ reason_text.append(f"评分{score}")
+ action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})"
+
+ conn.execute("""
+ INSERT INTO holding_strategies
+ (code, name, price, entry_low, entry_high, stop_loss, take_profit,
+ timing_signal, action, decision_type, strategy_type, status,
+ rr_ratio, stock_category, created_at, updated_at,
+ sector_context, quality_check)
+ VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan',
+ 'active',0,'关注',?,?,'', 'pending')
+ """, (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now))
+
+ conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
+ promoted += 1
+ print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True)
+
+ # 触发全量重评(生成完整9维策略)
+ try:
+ import subprocess as _sp
+ r = _sp.run(["python3", "/home/hmo/MoFin/scripts/per_stock_reassess.py", code],
+ capture_output=True, text=True, timeout=60)
+ if r.returncode == 0:
+ print(f" 重评完成", flush=True)
+ else:
+ print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True)
+ except Exception as e:
+ print(f" 重评异常: {e}", flush=True)
+
+ conn.commit()
+ print(f"\n[PROMOTE] 本次提拔{promoted}只", flush=True)
+
+ # 推XMPP
+ if promoted > 0:
+ try:
+ import urllib.request
+ msg = f"📈 自动提拔{promoted}只候选入自选"
+ 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"})
+ urllib.request.urlopen(req, timeout=5)
+ except Exception:
+ pass
+
+ conn.close()
+
+if __name__ == "__main__":
+ main()