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()