From 5d3b8e6fdd7a408432dae5b270e87c07052dc7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Mon, 20 Jul 2026 17:30:43 +0800 Subject: [PATCH] chore: capture deployed state --- data/mofin.db-shm | Bin 32768 -> 0 bytes data/mofin.db-wal | Bin 111272 -> 0 bytes data/price_events.json | 29640 ++++++++++++++++ data/price_history.json | 458 +- data/strategy_staleness_report.json | 833 +- deploy/profile-scripts/market_insight.py | 406 +- deploy/profile-scripts/market_scanner.py | 410 +- deploy/profile-scripts/mofin_health.py | 1924 +- .../profile-scripts/premarket_full_review.py | 123 +- deploy/profile-scripts/price_monitor.py | 1484 +- deploy/profile-scripts/promote_candidates.py | 271 +- .../strategy-staleness-check.py | 22 +- .../profile-scripts/sync_profile_scripts.sh | 13 + static/mofin_health.json | 720 +- 14 files changed, 33422 insertions(+), 2882 deletions(-) delete mode 100644 data/mofin.db-shm delete mode 100644 data/mofin.db-wal create mode 100755 deploy/profile-scripts/sync_profile_scripts.sh diff --git a/data/mofin.db-shm b/data/mofin.db-shm deleted file mode 100644 index 89b0a3b926ea8a7083089acce1ee93c2f7bf3c9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*J5B;o6vpv`FBD(+9<Y!f@k6m^uHGd_dsLoqK-tcamn!LiH&Hv+|POZx_uTS68-52YW}yPn-9R_4n=S z+w$S=>3$`N>)3hREf)z_D8FS@tY#>`&IX0181*9Eh>_OYQfl<@mWV)mdkm6r0X z>{QI|lr{By_n+zWxpJXgDp$(2a-)RVCf<;}a<`@EY;85Dt^0y+|iLcpnj4!fcda4Mi9-Y5i| z3UFwl(?6cEB@oUIr4Z;upwvkdJy8PVQQc`bPo=#e>mh&u0$md*+DzB(9ed*mT`&-P z5&Z}tfWRLG^e6}l0R#|0009ILKmdWB5}53%-Q;yM;pudg4}l?@_Aw|^B9LhTJ+6~N g009IL$eBQY&gNm9{0I!^XA~wuAW~p9vj6w?PlQQ1n*aa+ diff --git a/data/mofin.db-wal b/data/mofin.db-wal deleted file mode 100644 index 20e1ba2ba83777b214fc4bb7240593e39f601613..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111272 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,建议系统性减仓" + "[PORTFOLIO_WEAK] 组合中弱势+深套分类持仓占比64.3%>40%,建议系统性减仓" ] }, - "summary": "扫描58个策略,27个需关注" + "summary": "扫描128个策略,66个需关注" } \ No newline at end of file diff --git a/deploy/profile-scripts/market_insight.py b/deploy/profile-scripts/market_insight.py index 2bb438c3..1b65513b 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", 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() +#!/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() diff --git a/deploy/profile-scripts/market_scanner.py b/deploy/profile-scripts/market_scanner.py index 4d06b7ee..2bdb6b9d 100644 --- a/deploy/profile-scripts/market_scanner.py +++ b/deploy/profile-scripts/market_scanner.py @@ -1,205 +1,205 @@ -#!/usr/bin/env python3 -"""market_scanner.py — 全市场异动扫描(替代小果扫描线) - -每15分钟扫描: - 1. 板块轮动检测(从已采集的 sector_snapshots 读) - 2. 热门板块领涨股扫描(从腾讯API批量拉) - 3. 资金流向异常检测(从已有 capital_flow_cache 读) - 4. 输出候选股到 candidates 表 - -不依赖小果LLM,纯数据驱动。 -""" -import sys, json, sqlite3, urllib.request, re, time -from pathlib import Path -from datetime import datetime - -# XMPP 日志 hook -try: - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from xmpp_logger import log_xmpp -except ImportError: - def log_xmpp(*a, **kw): pass - -DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") -CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json") - -UA = "Mozilla/5.0" - -def get_conn(): - return sqlite3.connect(str(DB_PATH)) - -def fetch_qq_batch(symbols): - """腾讯批量行情""" - if not symbols: - return {} - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - proxy = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy) - with opener.open(req, timeout=15) as r: - text = r.read().decode("gbk") - results = {} - for line in text.strip().split("\n"): - if "~" not in line: - continue - parts = line.split("~") - if len(parts) < 40: - continue - m = re.search(r'_(\w+)=', parts[0]) - market = m.group(1) if m else "" - code = parts[2] - name = parts[1] - price = float(parts[3]) if parts[3] else 0 - chg_pct = float(parts[32]) if parts[32] else 0 - high = float(parts[33]) if parts[33] else 0 - low = float(parts[34]) if parts[34] else 0 - volume = int(parts[6]) if parts[6] else 0 - amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 - if price > 0: - results[code] = {"code": code, "name": name, "price": price, - "change_pct": chg_pct, "high": high, "low": low, - "volume": volume, "amount": amount, - "market": "SH" if market == "sh" else "SZ" if market == "sz" else "HK"} - return results - except Exception as e: - print(f"[SCANNER] 腾讯API错误: {e}", file=sys.stderr) - return {} - -def scan_hot_sectors(): - """从DB读热门板块,返回板块名+领涨股""" - conn = get_conn() - latest = conn.execute("SELECT MAX(id) FROM market_snapshots").fetchone()[0] - if not latest: - conn.close() - return [] - sectors = conn.execute(""" - SELECT name, change_pct, lead_stock, lead_stock_code, up_count, down_count - FROM sector_snapshots WHERE snapshot_id=? - ORDER BY change_pct DESC LIMIT 15 - """, (latest,)).fetchall() - conn.close() - return [{"name": s[0], "change": s[1], "lead_stock": s[2], - "lead_code": s[3], "up": s[4], "down": s[5]} for s in sectors if s[1] > 2.0] - -def scan_candidates(): - """主扫描流程""" - print(f"[SCANNER] {datetime.now().strftime('%H:%M')} 开始扫描", flush=True) - - # 1. 热门板块领涨股 - hot = scan_hot_sectors() - print(f" 热门板块(涨幅>2%): {len(hot)}个", flush=True) - - candidates = {} - - # 从热门板块拉领涨股 - lead_codes = [] - for s in hot: - if s.get("lead_code"): - lc = str(s["lead_code"]).strip() - if lc and lc not in candidates: - lead_codes.append(lc) - candidates[lc] = {"source": f"板块:{s['name']}(+{s['change']:.1f}%)", "sector": s["name"]} - - print(f" 领涨股待查: {len(lead_codes)}只", flush=True) - - # 2. 腾讯API批量查行情 - symbols = [] - for c in lead_codes: - if len(c) == 6: - if c.startswith(("5", "6", "9")): - symbols.append(f"sh{c}") - else: - symbols.append(f"sz{c}") - else: - symbols.append(f"hk{c}") - - prices = fetch_qq_batch(symbols) - print(f" 行情返回: {len(prices)}只", flush=True) - - # 3. 评估候选 - new_candidates = [] - for code, info in prices.items(): - if code not in candidates: - continue - src = candidates[code] - price = info["price"] - chg = info["change_pct"] - name = info["name"] - vol = info["volume"] - amt = info["amount"] - - # 条件:涨幅>3%,有量 - if chg < 3.0: - continue - if vol <= 0: - continue - - score = min(10, round(3 + chg * 0.5 + (amt / 1e8 if amt > 0 else 0) * 0.1, 1)) - - entry_low = round(price * 0.95, 2) - entry_high = round(price, 2) - stop_loss = round(price * 0.92, 2) - take_profit = round(price * 1.15, 2) - - candidate = { - "code": code, - "name": name, - "price": price, - "change_pct": chg, - "score": score, - "entry_low": entry_low, - "entry_high": entry_high, - "stop_loss": stop_loss, - "take_profit": take_profit, - "source": src["source"], - "sector": src.get("sector", ""), - "reason": f"热门板块{src['sector']}领涨+{chg:.1f}%" - } - new_candidates.append(candidate) - print(f" ✅ {code} {name} 价{price} (+{chg:.1f}%) 评分{score}", flush=True) - - # 4. 写入candidates表 - if new_candidates: - conn = get_conn() - for c in new_candidates: - conn.execute( - "INSERT OR REPLACE INTO candidates (code, name, price, change_pct, score, " - "entry_low, entry_high, stop_loss, take_profit, source, sector, reason, created_at) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))", - (c["code"], c["name"], c["price"], c["change_pct"], c["score"], - c["entry_low"], c["entry_high"], c["stop_loss"], c["take_profit"], - c["source"], c["sector"], c["reason"]) - ) - conn.commit() - conn.close() - print(f" ✅ 写入{len(new_candidates)}只候选", flush=True) - else: - print(f" ⚪ 无新候选", flush=True) - - # 5. 推送到Dad(只推高分) - high_score = [c for c in new_candidates if c["score"] >= 6] - if high_score: - lines = ["🔍 市场扫描发现潜在机会:"] - for c in high_score[:3]: - lines.append( - f" {c['name']}({c['code']}) 价{c['price']:.2f}(+{c['change_pct']:.1f}%) " - f"评分{c['score']}/10 | {c['reason']}" - ) - msg = "\n".join(lines) - # 推XMPP(通过知微 Bot HTTP 桥 :5805) - import time as _time - t0 = _time.time() - try: - 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) - print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True) - log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "ok", None, int((_time.time()-t0)*1000)) - except Exception as e: - print(f" ⚠️ 推送失败: {e}", file=sys.stderr) - log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "error", str(e)[:200], int((_time.time()-t0)*1000)) - -if __name__ == "__main__": - scan_candidates() +#!/usr/bin/env python3 +"""market_scanner.py — 全市场异动扫描(替代小果扫描线) + +每15分钟扫描: + 1. 板块轮动检测(从已采集的 sector_snapshots 读) + 2. 热门板块领涨股扫描(从腾讯API批量拉) + 3. 资金流向异常检测(从已有 capital_flow_cache 读) + 4. 输出候选股到 candidates 表 + +不依赖小果LLM,纯数据驱动。 +""" +import sys, json, sqlite3, urllib.request, re, time +from pathlib import Path +from datetime import datetime + +# XMPP 日志 hook +try: + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from xmpp_logger import log_xmpp +except ImportError: + def log_xmpp(*a, **kw): pass + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json") + +UA = "Mozilla/5.0" + +def get_conn(): + return sqlite3.connect(str(DB_PATH)) + +def fetch_qq_batch(symbols): + """腾讯批量行情""" + if not symbols: + return {} + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + results = {} + for line in text.strip().split("\n"): + if "~" not in line: + continue + parts = line.split("~") + if len(parts) < 40: + continue + m = re.search(r'_(\w+)=', parts[0]) + market = m.group(1) if m else "" + code = parts[2] + name = parts[1] + price = float(parts[3]) if parts[3] else 0 + chg_pct = float(parts[32]) if parts[32] else 0 + high = float(parts[33]) if parts[33] else 0 + low = float(parts[34]) if parts[34] else 0 + volume = int(parts[6]) if parts[6] else 0 + amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 + if price > 0: + results[code] = {"code": code, "name": name, "price": price, + "change_pct": chg_pct, "high": high, "low": low, + "volume": volume, "amount": amount, + "market": "SH" if market == "sh" else "SZ" if market == "sz" else "HK"} + return results + except Exception as e: + print(f"[SCANNER] 腾讯API错误: {e}", file=sys.stderr) + return {} + +def scan_hot_sectors(): + """从DB读热门板块,返回板块名+领涨股""" + conn = get_conn() + latest = conn.execute("SELECT MAX(id) FROM market_snapshots").fetchone()[0] + if not latest: + conn.close() + return [] + sectors = conn.execute(""" + SELECT name, change_pct, lead_stock, lead_stock_code, up_count, down_count + FROM sector_snapshots WHERE snapshot_id=? + ORDER BY change_pct DESC LIMIT 15 + """, (latest,)).fetchall() + conn.close() + return [{"name": s[0], "change": s[1], "lead_stock": s[2], + "lead_code": s[3], "up": s[4], "down": s[5]} for s in sectors if s[1] > 2.0] + +def scan_candidates(): + """主扫描流程""" + print(f"[SCANNER] {datetime.now().strftime('%H:%M')} 开始扫描", flush=True) + + # 1. 热门板块领涨股 + hot = scan_hot_sectors() + print(f" 热门板块(涨幅>2%): {len(hot)}个", flush=True) + + candidates = {} + + # 从热门板块拉领涨股 + lead_codes = [] + for s in hot: + if s.get("lead_code"): + lc = str(s["lead_code"]).strip() + if lc and lc not in candidates: + lead_codes.append(lc) + candidates[lc] = {"source": f"板块:{s['name']}(+{s['change']:.1f}%)", "sector": s["name"]} + + print(f" 领涨股待查: {len(lead_codes)}只", flush=True) + + # 2. 腾讯API批量查行情 + symbols = [] + for c in lead_codes: + if len(c) == 6: + if c.startswith(("5", "6", "9")): + symbols.append(f"sh{c}") + else: + symbols.append(f"sz{c}") + else: + symbols.append(f"hk{c}") + + prices = fetch_qq_batch(symbols) + print(f" 行情返回: {len(prices)}只", flush=True) + + # 3. 评估候选 + new_candidates = [] + for code, info in prices.items(): + if code not in candidates: + continue + src = candidates[code] + price = info["price"] + chg = info["change_pct"] + name = info["name"] + vol = info["volume"] + amt = info["amount"] + + # 条件:涨幅>3%,有量 + if chg < 3.0: + continue + if vol <= 0: + continue + + score = min(10, round(3 + chg * 0.5 + (amt / 1e8 if amt > 0 else 0) * 0.1, 1)) + + entry_low = round(price * 0.95, 2) + entry_high = round(price, 2) + stop_loss = round(price * 0.92, 2) + take_profit = round(price * 1.15, 2) + + candidate = { + "code": code, + "name": name, + "price": price, + "change_pct": chg, + "score": score, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": stop_loss, + "take_profit": take_profit, + "source": src["source"], + "sector": src.get("sector", ""), + "reason": f"热门板块{src['sector']}领涨+{chg:.1f}%" + } + new_candidates.append(candidate) + print(f" ✅ {code} {name} 价{price} (+{chg:.1f}%) 评分{score}", flush=True) + + # 4. 写入candidates表 + if new_candidates: + conn = get_conn() + for c in new_candidates: + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, price, change_pct, score, " + "entry_low, entry_high, stop_loss, take_profit, source, sector, reason, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))", + (c["code"], c["name"], c["price"], c["change_pct"], c["score"], + c["entry_low"], c["entry_high"], c["stop_loss"], c["take_profit"], + c["source"], c["sector"], c["reason"]) + ) + conn.commit() + conn.close() + print(f" ✅ 写入{len(new_candidates)}只候选", flush=True) + else: + print(f" ⚪ 无新候选", flush=True) + + # 5. 推送到Dad(只推高分) + high_score = [c for c in new_candidates if c["score"] >= 6] + if high_score: + lines = ["🔍 市场扫描发现潜在机会:"] + for c in high_score[:3]: + lines.append( + f" {c['name']}({c['code']}) 价{c['price']:.2f}(+{c['change_pct']:.1f}%) " + f"评分{c['score']}/10 | {c['reason']}" + ) + msg = "\n".join(lines) + # 推XMPP(通过知微 Bot HTTP 桥 :5805) + import time as _time + t0 = _time.time() + try: + 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) + print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True) + log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "ok", None, int((_time.time()-t0)*1000)) + except Exception as e: + print(f" ⚠️ 推送失败: {e}", file=sys.stderr) + log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "error", str(e)[:200], int((_time.time()-t0)*1000)) + +if __name__ == "__main__": + scan_candidates() diff --git a/deploy/profile-scripts/mofin_health.py b/deploy/profile-scripts/mofin_health.py index bc9198f8..d4ba0a8b 100644 --- a/deploy/profile-scripts/mofin_health.py +++ b/deploy/profile-scripts/mofin_health.py @@ -1,925 +1,999 @@ -#!/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() +#!/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() diff --git a/deploy/profile-scripts/premarket_full_review.py b/deploy/profile-scripts/premarket_full_review.py index 2a8a9319..a8c1060b 100644 --- a/deploy/profile-scripts/premarket_full_review.py +++ b/deploy/profile-scripts/premarket_full_review.py @@ -1,64 +1,59 @@ -#!/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 深度分析(每日强制,14只约8-10分钟) -print("\n" + "=" * 50) -print("🧠 持仓12维LLM分析(每日强制刷新)") -print("=" * 50) -import subprocess as _sp -analysis_result = {"ok": 0, "fail": 0, "skip": 0} -try: - r = _sp.run( - ["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/batch_reassess.py", - "--type", "holding", "--today"], - capture_output=True, text=True, timeout=3600) - print(r.stdout[-2000:] if len(r.stdout) > 2000 else r.stdout) - if r.returncode != 0 and r.stderr: - print(f"⚠️ stderr: {r.stderr[:300]}") - # 从输出尾部解析统计 - import re as _re - m = _re.search(r"完成: (\d+)成功, (\d+)失败, (\d+)跳过", r.stdout) - if m: - analysis_result = {"ok": int(m.group(1)), "fail": int(m.group(2)), "skip": int(m.group(3))} -except Exception as e: - print(f"⚠️ 12维分析步骤异常: {e}") - -# 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. 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✅ 盘前重评完毕") diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py index 5c1a3641..3532f0d7 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", 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") or 0) > 0} + for d in active: + code = d["code"] + # 非持仓跳过 + if code not in holdings_codes: + continue + name = d.get("name", code) + price_info = prices.get(code) + if not price_info: + continue + price, _, change_pct = price_info + if price == 0: + continue + # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发) + try: + cp = float(change_pct) if change_pct else 0 + except: + cp = 0 + if cp <= -7: + prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False) + if not prev_alert: + stop_loss = d.get("stop_loss", 0) + sl_note = f" 止损{stop_loss}" if stop_loss else "" + msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}" + push_to_xmpp(msg) + outputs.append(msg) + state.setdefault(code, {})["__sharp_decline_triggered"] = True + state_updated = True + # 立即持久化,防止后续超时导致状态丢失而重复推送 + save_state(state) + elif cp > -5: + # 反弹后清除告警标记,下次再跌还能报 + state.setdefault(code, {}).pop("__sharp_decline_triggered", None) + + for d in active: + code = d["code"] + name = d.get("name", code) + price_info = prices.get(code) + if not price_info: + continue + price, _, _ = price_info + if price == 0: + continue + + # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区 + entry_low = d.get("entry_low", 0) + entry_high = d.get("entry_high", 0) + if not entry_low or not entry_high: + continue + + in_buy_zone = entry_low <= price <= entry_high + prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) + + # 状态变化时才触发 + if in_buy_zone and prev_in_buy_zone == False: + # 重新进入买入区 → 重评确认区间是否仍然有效 + outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") + do_reassess = True + elif not in_buy_zone and prev_in_buy_zone == True: + # 离开买入区 → 立即重评,更新止损/止盈/区间 + outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") + do_reassess = True + else: + do_reassess = False + + if do_reassess and HAS_REASSESS: + try: + cost = d.get("cost", 0) or 0 + shares = d.get("shares", 0) or 0 + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss = profit_pct < -20 + sentiment = "neutral" + if d.get("tech_snapshot"): + if "bearish" in d["tech_snapshot"]: + sentiment = "bearish" + elif "bullish" in d["tech_snapshot"]: + sentiment = "bullish" + + # 调用技术面驱动重评(非机械百分比) + result = reassess_strategy( + code, name, price, cost, shares, + current_action=d.get("action", ""), + volume_signal="中性", sentiment=sentiment, + ) + outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") + reassesed_codes.append(code) + except Exception as e: + outputs.append(f" ⚠️ 重评失败: {e}") + + # 更新买入区状态 + if "__buy_zone" not in state.get(code, {}): + if code not in state: + state[code] = {} + state[code]["__buy_zone"] = in_buy_zone + state_updated = True + + # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃) + if reassesed_codes and HAS_REASSESS: + # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ── + _regen_marker = "/tmp/price_monitor_regen_at" + _skip_regen = False + try: + if os.path.exists(_regen_marker): + with open(_regen_marker) as _f: + _last_regen = float(_f.read().strip()) + if time.time() - _last_regen < 300: + _skip_regen = True + except: + pass + + if _skip_regen: + outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑") + else: + try: + from strategy_lifecycle import regenerate_all + r = regenerate_all(stdout=False) + outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") + outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") + try: + with open(_regen_marker, "w") as _f: + _f.write(str(time.time())) + except: + pass + except Exception as e: + outputs.append(f" ⚠️ 全量重评失败: {e}") + + # === 第四步:输出 === + now_str = datetime.now().strftime("%H:%M:%S") + elapsed = time.time() - start + + if outputs: + print(f"\n🔔 {now_str}{label}") + for o in outputs: + print(o) + print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") + else: + # 无触发时 SILENT(中继不推送) + print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") + + if state_updated: + save_state(state) + + # 输出耗时 + print(f"⏱{label} {elapsed:.1f}s", flush=True) + + # 清理进程锁 + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/promote_candidates.py b/deploy/profile-scripts/promote_candidates.py index 122b43b5..67a47e9a 100644 --- a/deploy/profile-scripts/promote_candidates.py +++ b/deploy/profile-scripts/promote_candidates.py @@ -1,135 +1,136 @@ -#!/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})" - - 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), 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() diff --git a/deploy/profile-scripts/strategy-staleness-check.py b/deploy/profile-scripts/strategy-staleness-check.py index db5c9ea3..a77bf060 100644 --- a/deploy/profile-scripts/strategy-staleness-check.py +++ b/deploy/profile-scripts/strategy-staleness-check.py @@ -13,7 +13,7 @@ import json, sys, os, re, urllib.request, sqlite3 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from datetime import datetime -from mo_data import read_decisions, read_portfolio, get_price +from mo_data import read_decisions, read_portfolio DB_PATH = '/home/hmo/web-dashboard/data/mofin.db' OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" @@ -23,7 +23,25 @@ CRITICAL_DAYS = 21 # 超过21天→严重警告 DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告 DIVERGENCE_CRIT = 50 # 偏离>50%→严重 -# ── 使用 mo_data.get_price 统一获取价格 ── +def get_price(code): + """从腾讯API获取当前价""" + try: + market = "sh" if code.startswith("6") else "sz" if code.startswith("0") or code.startswith("3") else "" + if code.startswith(("00", "30")) or code.startswith("68"): + market = "sh" if code.startswith("6") else "sz" + elif code.startswith(("01", "02", "03")): + market = "sz" + url = f"http://qt.gtimg.cn/q={market}{code}" + req = urllib.request.Request(url, headers={"User-Agent": "curl/7.81"}) + with urllib.request.urlopen(req, timeout=5) as resp: + raw = resp.read().decode("gbk") + parts = raw.split("~") + if len(parts) > 3: + price = float(parts[3]) if parts[3] else 0 + chg = float(parts[32]) if parts[32] else 0 + return price, chg if price > 0 else (None, None) + except: pass + return None, None def parse_buy_zone(current): """从策略current字段提取买入区间最低和最高""" diff --git a/deploy/profile-scripts/sync_profile_scripts.sh b/deploy/profile-scripts/sync_profile_scripts.sh new file mode 100755 index 00000000..a62832a4 --- /dev/null +++ b/deploy/profile-scripts/sync_profile_scripts.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# sync_profile_scripts.sh — 把 deploy/profile-scripts 全部硬链接到 profile scripts 目录 +# 每次部署(scp/git)后必须跑:scp替换文件会破坏硬链接(inode变更),导致cron跑旧版本。 +set -e +SRC="/home/hmo/MoFin/deploy/profile-scripts" +DST="/home/hmo/.hermes/profiles/position-analyst/scripts" +count=0 +for f in "$SRC"/*.py; do + name=$(basename "$f") + ln -f "$f" "$DST/$name" + count=$((count+1)) +done +echo "synced $count scripts (hardlink)" \ No newline at end of file diff --git a/static/mofin_health.json b/static/mofin_health.json index 9961c723..f53fc994 100644 --- a/static/mofin_health.json +++ b/static/mofin_health.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-07-17 22:45:05", + "generated_at": "2026-07-20 17:29:24", "feature_tree": { "label": "MoFin 系统", "status": "ok", @@ -18,7 +18,7 @@ "script": "market_watch.py", "schedule": "*/10 9-11,13-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50", + "last_run": "2026-07-20T15:50", "type": "no_agent", "profile": "position-analyst" } @@ -39,7 +39,7 @@ "script": "price_monitor.py", "schedule": "*/2 9-16 * * 1-5", "status": "error", - "last_run": "2026-07-17T17:00", + "last_run": "2026-07-20T16:59", "type": "no_agent", "profile": "position-analyst" } @@ -71,7 +71,7 @@ "script": "capital_flow_collector.py", "schedule": "1,31 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:31", + "last_run": "2026-07-20T15:32", "type": "no_agent", "profile": "position-analyst" } @@ -87,7 +87,7 @@ "script": "refresh_macro_context.py", "schedule": "*/30 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:30", + "last_run": "2026-07-20T15:31", "type": "no_agent", "profile": "position-analyst" } @@ -103,7 +103,7 @@ "script": "macro_context_collector.py", "schedule": "*/30 8-16 * * *", "status": "ok", - "last_run": "2026-07-17T16:31", + "last_run": "2026-07-20T16:30", "type": "no_agent", "profile": "position-analyst" } @@ -146,8 +146,8 @@ "name": "自选买入区提醒-盘前午间尾盘", "script": "per_stock_reassess.py", "schedule": "0 12,15 * * 1-5", - "status": "error", - "last_run": "2026-07-17T15:03", + "status": "ok", + "last_run": "2026-07-20T15:02", "type": "no_agent", "profile": "position-analyst" }, @@ -171,8 +171,8 @@ "name": "策略评估-每周", "script": "strategy_evaluator.py", "schedule": "0 21 * * 6", - "status": "ok", - "last_run": "2026-07-11T21:00", + "status": "error", + "last_run": "2026-07-18T21:00", "type": "no_agent", "profile": "position-analyst" }, @@ -206,7 +206,7 @@ "script": "branch_scanner.py", "schedule": "15,30,45,00 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:45", + "last_run": "2026-07-20T15:45", "type": "no_agent", "profile": "position-analyst" } @@ -222,7 +222,7 @@ "script": "meta_growth.py", "schedule": "45 0,12 * * 1-5", "status": "ok", - "last_run": "2026-07-17T12:45", + "last_run": "2026-07-20T12:45", "type": "no_agent", "profile": "position-analyst" } @@ -247,14 +247,14 @@ { "label": "自选股自动重评-周末 (stale_detector.py)", "desc": "周末批量重评自选股策略", - "status": "ok", + "status": "error", "pipes": [ { "name": "自选股自动重评-周末", "script": "stale_detector.py", "schedule": "30 10 * * 0,6", - "status": "ok", - "last_run": "2026-07-12T10:30", + "status": "error", + "last_run": "2026-07-19T10:30", "type": "no_agent", "profile": "position-analyst" } @@ -263,14 +263,14 @@ { "label": "盘前全量重评-自选退出 (premarket_full_review.py)", "desc": "", - "status": "ok", + "status": "error", "pipes": [ { "name": "盘前全量重评-自选退出", "script": "premarket_full_review.py", "schedule": "10 8 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T08:11", + "status": "error", + "last_run": "2026-07-20T08:12", "type": "no_agent", "profile": "position-analyst" } @@ -325,7 +325,7 @@ "script": "cron_to_xmpp.py", "schedule": "*/5 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:55", + "last_run": "2026-07-20T16:55", "type": "no_agent", "profile": "position-analyst" } @@ -372,8 +372,8 @@ "name": "市场精选推荐-每日", "script": null, "schedule": "0 16 * * 1-5", - "status": "error", - "last_run": "2026-07-16T16:04", + "status": "ok", + "last_run": "2026-07-20T16:09", "type": "LLM", "profile": "position-analyst" } @@ -389,7 +389,7 @@ "script": "system_health_check.py", "schedule": "0 9 * * 1-5", "status": "ok", - "last_run": "2026-07-17T10:34", + "last_run": "2026-07-20T10:38", "type": "LLM", "profile": "position-analyst" } @@ -405,7 +405,7 @@ "script": "morning_health_check.py", "schedule": "0 8 * * 1-5", "status": "ok", - "last_run": "2026-07-17T08:01", + "last_run": "2026-07-20T08:01", "type": "no_agent", "profile": "position-analyst" } @@ -414,14 +414,14 @@ { "label": "开盘前钉对钉验证 (preflight_verify.py)", "desc": "开盘前15项验证(脚本同步/DB完整性/资产公式)", - "status": "error", + "status": "ok", "pipes": [ { "name": "开盘前钉对钉验证", "script": "preflight_verify.py", "schedule": "30 8 * * 1-5", - "status": "error", - "last_run": "2026-07-17T08:31", + "status": "ok", + "last_run": "2026-07-20T08:30", "type": "no_agent", "profile": "position-analyst" } @@ -437,7 +437,7 @@ "script": "cron_to_xmpp.py", "schedule": "* 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:59", + "last_run": "2026-07-20T16:59", "type": "no_agent", "profile": "default" } @@ -460,7 +460,7 @@ "script": null, "schedule": "30 8 * * 1-5", "status": "ok", - "last_run": "2026-07-17T08:42", + "last_run": "2026-07-20T08:43", "type": "LLM", "profile": "position-analyst" }, @@ -477,8 +477,8 @@ "name": "宏观风险扫描-周末", "script": null, "schedule": "0 10 * * 0,6", - "status": "error", - "last_run": "2026-07-12T10:00", + "status": "ok", + "last_run": "2026-07-19T10:11", "type": "LLM", "profile": "position-analyst" } @@ -494,7 +494,7 @@ "script": "macro_signal_consumer.py", "schedule": "5,20,35,50 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50", + "last_run": "2026-07-20T15:50", "type": "no_agent", "profile": "position-analyst" } @@ -510,7 +510,7 @@ "script": "divergence_detector.py", "schedule": "*/30 8-15 * * *", "status": "ok", - "last_run": "2026-07-17T15:30", + "last_run": "2026-07-20T15:31", "type": "no_agent", "profile": "position-analyst" }, @@ -518,8 +518,8 @@ "name": "跨市场背离检测-周末", "script": "divergence_detector.py", "schedule": "30 8-16/2 * * 0,6", - "status": "ok", - "last_run": "2026-07-12T16:30", + "status": "error", + "last_run": "2026-07-19T16:30", "type": "no_agent", "profile": "position-analyst" } @@ -558,7 +558,7 @@ "script": "cron_health_monitor.py", "schedule": "*/10 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:50", + "last_run": "2026-07-20T16:51", "type": "no_agent", "profile": "position-analyst" } @@ -574,7 +574,7 @@ "script": "verify_reassess_pipeline.py", "schedule": "*/30 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:31", + "last_run": "2026-07-20T16:30", "type": "no_agent", "profile": "position-analyst" } @@ -589,8 +589,8 @@ "name": "健康监控数据采集-每15分", "script": "mofin_health.py", "schedule": "*/15 9-16,20-22 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T22:30", + "status": "error", + "last_run": "2026-07-20T16:45", "type": "no_agent", "profile": "position-analyst" } @@ -606,7 +606,7 @@ "script": "hardcode_scanner.py", "schedule": "25 17 * * 1-5", "status": "ok", - "last_run": "2026-07-17T17:26", + "last_run": "2026-07-20T17:26", "type": "no_agent", "profile": "position-analyst" } @@ -622,7 +622,7 @@ "script": "intraday_health_check.py", "schedule": "*/15 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:45", + "last_run": "2026-07-20T15:45", "type": "no_agent", "profile": "position-analyst" } @@ -631,14 +631,14 @@ { "label": "数据治理-每周 (data_governance.py)", "desc": "每周数据治理", - "status": "ok", + "status": "error", "pipes": [ { "name": "数据治理-每周", "script": "data_governance.py", "schedule": "0 10 * * 6", - "status": "ok", - "last_run": "2026-07-11T10:00", + "status": "error", + "last_run": "2026-07-18T10:00", "type": "no_agent", "profile": "position-analyst" } @@ -654,7 +654,7 @@ "script": "memory_guardian.py", "schedule": "0 7 * * *", "status": "ok", - "last_run": "2026-07-17T07:00", + "last_run": "2026-07-20T07:02", "type": "no_agent", "profile": "position-analyst" } @@ -677,7 +677,7 @@ "script": "self_todo_executor.py", "schedule": "*/10 8-22 * * 1-5", "status": "ok", - "last_run": "2026-07-17T22:40", + "last_run": "2026-07-20T17:20", "type": "no_agent", "profile": "position-analyst" } @@ -693,7 +693,7 @@ "script": "review_needed_watchdog.py", "schedule": "0,30 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:30", + "last_run": "2026-07-20T15:30", "type": "no_agent", "profile": "position-analyst" } @@ -709,7 +709,7 @@ "script": "clean_watchlist.py", "schedule": "5 9 * * 1-5", "status": "ok", - "last_run": "2026-07-17T09:05", + "last_run": "2026-07-20T09:06", "type": "no_agent", "profile": "position-analyst" } @@ -724,8 +724,8 @@ "name": "建议对账-每周", "script": "advice_reconciliation.py", "schedule": "0 20 * * 6", - "status": "ok", - "last_run": "2026-07-11T20:00", + "status": "error", + "last_run": "2026-07-18T20:00", "type": "no_agent", "profile": "position-analyst" } @@ -815,7 +815,7 @@ "script": "monitor_300308.py", "schedule": "*/2 13-14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T14:58", + "last_run": "2026-07-20T14:58", "type": "no_agent", "profile": "position-analyst" } @@ -831,7 +831,7 @@ "script": "300308_monitor.py", "schedule": "*/2 13-14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T14:58", + "last_run": "2026-07-20T14:58", "type": "no_agent", "profile": "position-analyst" } @@ -847,7 +847,7 @@ "script": "refresh_mtf_cache.py", "schedule": "0 9,11,14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T11:01", + "last_run": "2026-07-20T14:02", "type": "no_agent", "profile": "position-analyst" } @@ -862,14 +862,14 @@ { "label": "知微洞察生成 (market_insight.py)", "desc": "生成每日市场洞察(15:35)", - "status": "ok", + "status": "error", "pipes": [ { "name": "知微洞察生成", "script": "market_insight.py", "schedule": "35 15 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T15:36", + "status": "error", + "last_run": "2026-07-20T15:35", "type": "no_agent", "profile": "position-analyst" } @@ -885,7 +885,7 @@ "script": "market_screener.py", "schedule": "35 15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:37", + "last_run": "2026-07-20T15:35", "type": "no_agent", "profile": "position-analyst" } @@ -917,7 +917,7 @@ "script": "accumulation_scanner.py", "schedule": "*/10 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50", + "last_run": "2026-07-20T15:50", "type": "no_agent", "profile": "position-analyst" } @@ -933,7 +933,7 @@ "script": "promote_candidates.py", "schedule": "*/30 9-15 * * 1-5", "status": "error", - "last_run": "2026-07-17T15:30", + "last_run": "2026-07-20T15:31", "type": "no_agent", "profile": "position-analyst" } @@ -942,30 +942,14 @@ { "label": "候选股过滤管道-每30分 (candidate_filter.py)", "desc": "", - "status": "error", + "status": "ok", "pipes": [ { "name": "候选股过滤管道-每30分", "script": "candidate_filter.py", "schedule": "*/30 9-15 * * 1-5", - "status": "error", - "last_run": "2026-07-17T15:30", - "type": "no_agent", - "profile": "position-analyst" - } - ] - }, - { - "label": "批量补全九维分析-一次性 (batch_reassess.py)", - "desc": "", - "status": null, - "pipes": [ - { - "name": "批量补全九维分析-一次性", - "script": "batch_reassess.py", - "schedule": "once at 2026-07-09 23:00", - "status": null, - "last_run": "", + "status": "ok", + "last_run": "2026-07-20T15:31", "type": "no_agent", "profile": "position-analyst" } @@ -981,7 +965,7 @@ "script": "fix_gateway_port.py", "schedule": "every 10m", "status": "ok", - "last_run": "2026-07-17T22:37", + "last_run": "2026-07-20T17:29", "type": "no_agent", "profile": "position-analyst" } @@ -997,7 +981,7 @@ "script": "sync_dashboard.py", "schedule": "55 8 * * *", "status": "ok", - "last_run": "2026-07-17T08:55", + "last_run": "2026-07-20T08:55", "type": "no_agent", "profile": "default" } @@ -1012,14 +996,14 @@ { "label": "state.db真空整理-每周 (vacuum_state_db.py)", "desc": "每周DB真空整理", - "status": "ok", + "status": "error", "pipes": [ { "name": "state.db真空整理-每周", "script": "vacuum_state_db.py", "schedule": "0 3 * * 6", - "status": "ok", - "last_run": "2026-07-11T03:00", + "status": "error", + "last_run": "2026-07-18T03:00", "type": "no_agent", "profile": "position-analyst" } @@ -1060,8 +1044,8 @@ "desc": "建议执行时间线", "rows": 0, "readers": [ - "advice_reconciliation", - "mofin_db" + "mofin_db", + "advice_reconciliation" ], "writers": [ "advice_reconciliation" @@ -1113,14 +1097,14 @@ "rows": 0, "readers": [ "mofin_db", - "promote_candidates", "candidate_filter", + "promote_candidates", "accumulation_scanner" ], "writers": [ - "promote_candidates", "market_scanner", "candidate_filter", + "promote_candidates", "accumulation_scanner" ], "has_input": true, @@ -1144,9 +1128,9 @@ "desc": "资金流缓存", "rows": 1, "readers": [ + "batch_reassess", "mofin_db", - "per_stock_reassess", - "batch_reassess" + "per_stock_reassess" ], "writers": [ "mofin_db" @@ -1171,8 +1155,8 @@ "desc": "资金变动记录", "rows": 0, "readers": [ - "prepare_report_data", - "mofin_db" + "mofin_db", + "prepare_report_data" ], "writers": [ "mo_data", @@ -1200,24 +1184,24 @@ "desc": "每只股票的完整策略参数", "rows": 0, "readers": [ + "system_health_check", "data_governance", - "run_all_tests", - "capital_flow_collector", - "accumulation_scanner", - "mofin_collect", - "price_monitor", - "watchlist_auto_exit", "candidate_filter", - "mofin_db", - "promote_candidates" + "sync_decisions_to_db", + "batch_reassess", + "check_db_state", + "mo_data", + "generate_report", + "verify_reassess_pipeline", + "stale_detector" ], "writers": [ - "promote_candidates", - "data_governance", "per_stock_reassess", - "batch_reassess", - "watchlist_auto_exit", + "data_governance", "sync_decisions_to_db", + "batch_reassess", + "promote_candidates", + "watchlist_auto_exit", "mofin_db" ], "has_input": true, @@ -1246,21 +1230,21 @@ "desc": "当前持仓(权威源)", "rows": 0, "readers": [ + "system_health_check", + "candidate_filter", "mo_alphasift_bridge", - "run_all_tests", - "capital_flow_collector", - "accumulation_scanner", - "mofin_collect", - "server", + "xiaoguo_scanner", + "mo_data", + "refresh_mtf_cache", + "trend_detector", "prepare_recommendation", - "price_monitor", - "xiaoguo_signal_consumer", - "candidate_filter" + "server", + "per_stock_reassess" ], "writers": [ "mofin_db", - "price_monitor", - "import_holding_xls" + "import_holding_xls", + "price_monitor" ], "has_input": true, "has_output": true, @@ -1288,20 +1272,20 @@ "desc": "所有持仓+自选最新实时价", "rows": 0, "readers": [ + "verify_reassess_pipeline", + "strategy_lifecycle", + "candidate_filter", "stale_push_wlin", "mo_data", - "system_audit", - "candidate_filter", - "strategy_lifecycle", + "mofin_db", "cron_health_monitor", "generate_report", - "verify_reassess_pipeline", - "mofin_db" + "system_audit" ], "writers": [ - "price_monitor", "mo_data", - "mofin_db" + "mofin_db", + "price_monitor" ], "has_input": true, "has_output": true, @@ -1325,15 +1309,15 @@ { "name": "market_snapshots", "desc": "大盘指数快照(每10分)", - "rows": 196, + "rows": 232, "readers": [ - "market_screener", "trend_detector", - "prepare_report_data", "market_scanner", - "system_audit", "mofin_query", - "mofin_db" + "mofin_db", + "market_screener", + "system_audit", + "prepare_report_data" ], "writers": [ "mofin_db" @@ -1361,13 +1345,13 @@ "desc": "多周期均线缓存", "rows": 0, "readers": [ - "multi_timeframe", "technical_analysis", - "mofin_db" + "mofin_db", + "multi_timeframe" ], "writers": [ - "multi_timeframe", - "mofin_db" + "mofin_db", + "multi_timeframe" ], "has_input": true, "has_output": true, @@ -1392,17 +1376,19 @@ "desc": "总资产/现金/仓位汇总", "rows": 0, "readers": [ - "import_holding_xls", - "mo_data", - "prepare_report_data", "price_monitor", + "batch_reassess", + "import_holding_xls", + "check_db_state", "preflight_verify", - "mofin_db" + "mo_data", + "mofin_db", + "prepare_report_data" ], "writers": [ "mofin_db", - "price_monitor", - "import_holding_xls" + "import_holding_xls", + "price_monitor" ], "has_input": true, "has_output": true, @@ -1454,14 +1440,14 @@ "desc": "行业信号(趋势检测产出)", "rows": 0, "readers": [ + "mofin_news", "server", "trend_detector", - "mofin_news", "xiaoguo_news_processor" ], "writers": [ - "trend_detector", "mofin_news", + "trend_detector", "xiaoguo_news_processor" ], "has_input": true, @@ -1487,13 +1473,13 @@ { "name": "sector_snapshots", "desc": "行业板块数据", - "rows": 15150, + "rows": 17340, "readers": [ - "market_screener", "trend_detector", "market_scanner", + "mofin_db", "strategy_lifecycle", - "mofin_db" + "market_screener" ], "writers": [ "mofin_db" @@ -1522,21 +1508,21 @@ "rows": 0, "readers": [ "server", - "intraday_health_check", "per_stock_reassess", + "intraday_health_check", + "batch_reassess", "xiaoguo_signal_consumer", - "system_audit", "macro_signal_consumer", - "batch_reassess" + "system_audit" ], "writers": [ + "macro_context_collector", "mofin_news", "xiaoguo_news_processor", - "xiaoguo_signal_consumer", "divergence_detector", "xiaoguo_scanner", "macro_signal_consumer", - "macro_context_collector" + "xiaoguo_signal_consumer" ], "has_input": true, "has_output": true, @@ -1629,10 +1615,10 @@ "readers": [ "trend_detector", "mofin_news", - "xiaoguo_news_processor", "per_stock_reassess", - "strategy_lifecycle", - "mofin_db" + "xiaoguo_news_processor", + "mofin_db", + "strategy_lifecycle" ], "writers": [ "mofin_db" @@ -1680,13 +1666,13 @@ "trend_detector", "mofin_news", "xiaoguo_news_processor", + "mofin_db", "accumulation_scanner", - "import_full_stocks", - "mofin_db" + "import_full_stocks" ], "writers": [ - "import_full_stocks", - "mofin_db" + "mofin_db", + "import_full_stocks" ], "has_input": true, "has_output": true, @@ -1711,8 +1697,8 @@ "desc": "策略重评历史记录", "rows": 0, "readers": [ - "verify_reassess_pipeline", - "mofin_db" + "mofin_db", + "verify_reassess_pipeline" ], "writers": [ "mofin_collect" @@ -1764,21 +1750,21 @@ { "name": "todos", "desc": "自愈任务队列", - "rows": 3, + "rows": 4, "readers": [ - "strategy-staleness-check", - "morning_health_check", - "self_todo_executor", - "intraday_health_check" - ], - "writers": [ - "morning_health_check", "intraday_health_check", "strategy-staleness-check", + "self_todo_executor", + "morning_health_check" + ], + "writers": [ + "self_todo_executor", + "morning_health_check", + "strategy-staleness-check", + "intraday_health_check", "preflight_verify", - "cron_health_monitor", "mofin_collect", - "self_todo_executor" + "cron_health_monitor" ], "has_input": true, "has_output": true, @@ -1808,19 +1794,19 @@ "rows": 0, "readers": [ "trend_detector", - "stale_push_wlin", - "mo_alphasift_bridge", - "run_all_tests", - "stock_quote", "per_stock_reassess", + "run_all_tests", "price_monitor", - "xiaoguo_signal_consumer", - "refresh_mtf_cache", - "mofin_collect" + "mo_alphasift_bridge", + "mofin_db", + "stock_quote", + "xiaoguo_scanner", + "stale_push_wlin", + "xiaoguo_signal_consumer" ], "writers": [ - "per_stock_reassess", - "mofin_db" + "mofin_db", + "per_stock_reassess" ], "has_input": true, "has_output": true, @@ -1877,7 +1863,8 @@ "readers": [], "writers": [], "last_modified": "07-06 11:32", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "analysis_output.json", @@ -1886,7 +1873,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "candidate_pool.json", @@ -1895,7 +1883,8 @@ "readers": [], "writers": [], "last_modified": "07-17 15:38", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "capital_flow_cache.json", @@ -1904,7 +1893,8 @@ "readers": [], "writers": [], "last_modified": "07-01 15:32", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "daily_reviews.json", @@ -1913,7 +1903,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "decisions_backup.json", @@ -1922,7 +1913,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "decisions_backup_1129.json", @@ -1931,7 +1923,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "decisions_backup_before_tagfix.json", @@ -1940,7 +1933,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "evaluation.json", @@ -1949,7 +1943,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "evaluation_input.json", @@ -1958,7 +1953,8 @@ "readers": [], "writers": [], "last_modified": "07-08 19:50", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "format_error_library.json", @@ -1967,7 +1963,8 @@ "readers": [], "writers": [], "last_modified": "07-12 03:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "growth_registry.json", @@ -1975,17 +1972,19 @@ "size_kb": 1.3, "readers": [], "writers": [], - "last_modified": "07-14 12:45", - "warn": true + "last_modified": "07-20 12:45", + "warn": true, + "migrated_to_db": null }, { "name": "hardcode_audit.json", "desc": "", - "size_kb": 3430.7, + "size_kb": 3680.4, "readers": [], "writers": [], - "last_modified": "07-17 17:26", - "warn": true + "last_modified": "07-20 17:26", + "warn": true, + "migrated_to_db": null }, { "name": "health_checklist.json", @@ -1993,62 +1992,81 @@ "size_kb": 12.2, "readers": [], "writers": [], - "last_modified": "07-17 08:00", - "warn": true + "last_modified": "07-20 08:00", + "warn": true, + "migrated_to_db": null }, { "name": "live_prices.json", - "desc": "", + "desc": "(已迁移到DB表 live_prices,此为遗留文件)", "size_kb": 2.8, "readers": [], "writers": [], "last_modified": "07-06 11:00", - "warn": true + "warn": false, + "migrated_to_db": "live_prices" }, { "name": "macro_context.json", - "desc": "宏观上下文JSON(旧兼容层)", + "desc": "宏观上下文JSON(旧兼容层) (已迁移到DB表 macro_context_log,此为遗留文件)", "size_kb": 3.5, "readers": [], "writers": [], "last_modified": "07-06 11:32", - "warn": true + "warn": false, + "migrated_to_db": "macro_context_log" }, { "name": "macro_divergence_state.json", "desc": "", - "size_kb": 2.2, + "size_kb": 0.3, "readers": [], "writers": [], - "last_modified": "07-17 15:30", - "warn": true + "last_modified": "07-20 15:31", + "warn": true, + "migrated_to_db": null }, { "name": "macro_risk_state.json", - "desc": "宏观风险状态(采集器写入)", - "size_kb": 1.3, + "desc": "宏观风险状态(采集器写入) (已迁移到DB表 macro_context_log,此为遗留文件)", + "size_kb": 0.8, "readers": [], "writers": [], - "last_modified": "07-17 16:31", - "warn": true + "last_modified": "07-20 16:30", + "warn": false, + "migrated_to_db": "macro_context_log" + }, + { + "name": "market.json", + "desc": "市场概况数据 (已迁移到DB表 market_snapshots,此为遗留文件)", + "size_kb": 0.2, + "readers": [], + "writers": [], + "last_modified": "07-20 17:23", + "warn": false, + "migrated_to_db": "market_snapshots" }, { "name": "mofin_health.json", "desc": "健康监控数据", - "size_kb": 84.0, - "readers": [], + "size_kb": 85.7, + "readers": [ + "analyze_health" + ], "writers": [], - "last_modified": "07-17 22:30", - "warn": true + "last_modified": "07-20 17:23", + "warn": false, + "migrated_to_db": null }, { "name": "multi_tf_cache.json", - "desc": "多周期均线缓存", + "desc": "多周期均线缓存 (已迁移到DB表 mtf_cache,此为遗留文件)", "size_kb": 1006.0, "readers": [], "writers": [], "last_modified": "07-06 11:32", - "warn": true + "warn": false, + "migrated_to_db": "mtf_cache" }, { "name": "new_opportunities.json", @@ -2057,7 +2075,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "pipeline_registry.json", @@ -2066,18 +2085,20 @@ "readers": [], "writers": [], "last_modified": "07-17 08:01", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "portfolio.json", "desc": "持仓汇总(兼容层)", - "size_kb": 4.3, + "size_kb": 5.1, "readers": [ "get_realtime_prices" ], "writers": [], - "last_modified": "07-17 14:09", - "warn": false + "last_modified": "07-20 08:03", + "warn": false, + "migrated_to_db": null }, { "name": "preflight_result.json", @@ -2085,26 +2106,29 @@ "size_kb": 2.2, "readers": [], "writers": [], - "last_modified": "07-17 08:31", - "warn": true + "last_modified": "07-20 08:30", + "warn": true, + "migrated_to_db": null }, { "name": "price_events.json", "desc": "", - "size_kb": 287.5, + "size_kb": 1067.5, "readers": [], "writers": [], - "last_modified": "07-17 16:59", - "warn": true + "last_modified": "07-20 17:06", + "warn": true, + "migrated_to_db": null }, { "name": "price_history.json", - "desc": "", - "size_kb": 19.4, + "desc": "(已迁移到DB表 price_events,此为遗留文件)", + "size_kb": 33.7, "readers": [], "writers": [], - "last_modified": "07-17 21:13", - "warn": true + "last_modified": "07-20 17:08", + "warn": false, + "migrated_to_db": "price_events" }, { "name": "push_cooldown.json", @@ -2113,7 +2137,8 @@ "readers": [], "writers": [], "last_modified": "07-10 09:32", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "scanner_state.json", @@ -2121,8 +2146,9 @@ "size_kb": 0.1, "readers": [], "writers": [], - "last_modified": "07-17 15:45", - "warn": true + "last_modified": "07-20 15:45", + "warn": true, + "migrated_to_db": null }, { "name": "sector_recommendation_test.json", @@ -2131,7 +2157,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "stock_name_code_cache.json", @@ -2140,7 +2167,8 @@ "readers": [], "writers": [], "last_modified": "06-22 19:58", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "stock_profiles.json", @@ -2149,7 +2177,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "stock_sector_map.json", @@ -2158,7 +2187,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "strategy_feedback.json", @@ -2167,16 +2197,18 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "strategy_staleness_report.json", "desc": "策略过期报告", - "size_kb": 14.3, + "size_kb": 31.4, "readers": [], "writers": [], - "last_modified": "07-17 09:00", - "warn": true + "last_modified": "07-20 09:00", + "warn": true, + "migrated_to_db": null }, { "name": "system_audit.json", @@ -2185,7 +2217,8 @@ "readers": [], "writers": [], "last_modified": "07-07 17:33", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "system_audit_report.json", @@ -2194,7 +2227,8 @@ "readers": [], "writers": [], "last_modified": "07-17 17:30", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "system_inventory.json", @@ -2203,7 +2237,8 @@ "readers": [], "writers": [], "last_modified": "07-08 11:34", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "system_state.json", @@ -2212,7 +2247,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "xiaoguo_insights.json", @@ -2221,7 +2257,8 @@ "readers": [], "writers": [], "last_modified": "07-07 16:14", - "warn": true + "warn": true, + "migrated_to_db": null }, { "name": "xiaoguo_sentiment.json", @@ -2230,7 +2267,8 @@ "readers": [], "writers": [], "last_modified": "06-20 12:15", - "warn": true + "warn": true, + "migrated_to_db": null } ], "pipelines": [ @@ -2240,7 +2278,7 @@ "script": "monitor_300308.py", "schedule": "*/2 13-14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T14:58:53", + "last_run": "2026-07-20T14:58:41", "profile": "position-analyst" }, { @@ -2249,7 +2287,7 @@ "script": "300308_monitor.py", "schedule": "*/2 13-14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T14:58:52", + "last_run": "2026-07-20T14:58:41", "profile": "position-analyst" }, { @@ -2258,7 +2296,7 @@ "script": "cron_watchdog.py", "schedule": "*/15 9-16 * * 1-5", "status": "error", - "last_run": "2026-07-17T16:45:47", + "last_run": "2026-07-20T16:45:59", "profile": "default" }, { @@ -2267,7 +2305,7 @@ "script": "fix_gateway_port.py", "schedule": "every 10m", "status": "ok", - "last_run": "2026-07-17T22:37:17", + "last_run": "2026-07-20T17:29:09", "profile": "position-analyst" }, { @@ -2276,7 +2314,7 @@ "script": "morning_health_check.py", "schedule": "0 8 * * 1-5", "status": "ok", - "last_run": "2026-07-17T08:01:23", + "last_run": "2026-07-20T08:01:00", "profile": "position-analyst" }, { @@ -2285,7 +2323,7 @@ "script": "bot_monitor.sh", "schedule": "every 60m", "status": "ok", - "last_run": "2026-07-17T22:36:01", + "last_run": "2026-07-20T17:03:27", "profile": "default" }, { @@ -2294,7 +2332,7 @@ "script": "cron_to_xmpp.py", "schedule": "* 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:59:07", + "last_run": "2026-07-20T16:59:26", "profile": "default" }, { @@ -2303,7 +2341,7 @@ "script": "cron_to_xmpp.py", "schedule": "*/5 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:55:43", + "last_run": "2026-07-20T16:55:44", "profile": "position-analyst" }, { @@ -2312,7 +2350,7 @@ "script": null, "schedule": "0 * * * *", "status": "error", - "last_run": "2026-07-17T22:13:38", + "last_run": "2026-07-20T16:06:26", "profile": "default" }, { @@ -2320,8 +2358,8 @@ "type": "no_agent", "script": "vacuum_state_db.py", "schedule": "0 3 * * 6", - "status": "ok", - "last_run": "2026-07-11T03:00:22", + "status": "error", + "last_run": "2026-07-18T03:00:55", "profile": "position-analyst" }, { @@ -2330,7 +2368,7 @@ "script": "wiki_health_aggregate.py", "schedule": "0 3 * * *", "status": "error", - "last_run": "2026-07-17T03:04:41", + "last_run": "2026-07-20T03:04:14", "profile": "default" }, { @@ -2339,7 +2377,7 @@ "script": "xiaoguo_quick_scan.py", "schedule": "*/15 * * * *", "status": "ok", - "last_run": "2026-07-17T22:45:03", + "last_run": "2026-07-20T17:15:19", "profile": "default" }, { @@ -2348,7 +2386,7 @@ "script": "xiaoguo_tunnel_watchdog.sh", "schedule": "*/5 * * * *", "status": "ok", - "last_run": "2026-07-17T22:45:03", + "last_run": "2026-07-20T17:25:18", "profile": "default" }, { @@ -2357,7 +2395,7 @@ "script": "accumulation_scanner.py", "schedule": "*/10 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50:36", + "last_run": "2026-07-20T15:50:33", "profile": "position-analyst" }, { @@ -2366,7 +2404,7 @@ "script": "price_monitor.py", "schedule": "*/2 9-16 * * 1-5", "status": "error", - "last_run": "2026-07-17T17:00:45", + "last_run": "2026-07-20T16:59:34", "profile": "position-analyst" }, { @@ -2375,7 +2413,7 @@ "script": "promote_candidates.py", "schedule": "*/30 9-15 * * 1-5", "status": "error", - "last_run": "2026-07-17T15:30:52", + "last_run": "2026-07-20T15:31:09", "profile": "position-analyst" }, { @@ -2383,8 +2421,8 @@ "type": "no_agent", "script": "candidate_filter.py", "schedule": "*/30 9-15 * * 1-5", - "status": "error", - "last_run": "2026-07-17T15:30:50", + "status": "ok", + "last_run": "2026-07-20T15:31:06", "profile": "position-analyst" }, { @@ -2392,8 +2430,8 @@ "type": "no_agent", "script": "mofin_health.py", "schedule": "*/15 9-16,20-22 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T22:30:06", + "status": "error", + "last_run": "2026-07-20T16:45:41", "profile": "position-analyst" }, { @@ -2402,7 +2440,7 @@ "script": "meta_growth.py", "schedule": "45 0,12 * * 1-5", "status": "ok", - "last_run": "2026-07-17T12:45:26", + "last_run": "2026-07-20T12:45:47", "profile": "position-analyst" }, { @@ -2411,7 +2449,7 @@ "script": "cron_health_monitor.py", "schedule": "*/10 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:50:43", + "last_run": "2026-07-20T16:51:10", "profile": "position-analyst" }, { @@ -2429,7 +2467,7 @@ "script": "branch_scanner.py", "schedule": "15,30,45,00 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:45:15", + "last_run": "2026-07-20T15:45:12", "profile": "position-analyst" }, { @@ -2447,7 +2485,7 @@ "script": "refresh_mtf_cache.py", "schedule": "0 9,11,14 * * 1-5", "status": "ok", - "last_run": "2026-07-17T11:01:55", + "last_run": "2026-07-20T14:02:11", "profile": "position-analyst" }, { @@ -2455,8 +2493,8 @@ "type": "LLM", "script": "", "schedule": "{'kind': 'cron', 'expr': '*/10 * * * *'}", - "status": "error", - "last_run": "2026-07-17T22:44:17", + "status": "ok", + "last_run": "2026-07-20T17:20:50", "profile": "default" }, { @@ -2465,7 +2503,7 @@ "script": "refresh_macro_context.py", "schedule": "*/30 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:30:48", + "last_run": "2026-07-20T15:31:07", "profile": "position-analyst" }, { @@ -2474,7 +2512,7 @@ "script": "macro_context_collector.py", "schedule": "*/30 8-16 * * *", "status": "ok", - "last_run": "2026-07-17T16:31:07", + "last_run": "2026-07-20T16:30:58", "profile": "position-analyst" }, { @@ -2483,7 +2521,7 @@ "script": "macro_signal_consumer.py", "schedule": "5,20,35,50 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50:20", + "last_run": "2026-07-20T15:50:18", "profile": "position-analyst" }, { @@ -2492,7 +2530,7 @@ "script": null, "schedule": "30 8 * * 1-5", "status": "ok", - "last_run": "2026-07-17T08:42:57", + "last_run": "2026-07-20T08:43:03", "profile": "position-analyst" }, { @@ -2509,8 +2547,8 @@ "type": "LLM", "script": null, "schedule": "0 10 * * 0,6", - "status": "error", - "last_run": "2026-07-12T10:00:38", + "status": "ok", + "last_run": "2026-07-19T10:11:31", "profile": "position-analyst" }, { @@ -2519,7 +2557,7 @@ "script": "market_screener.py", "schedule": "35 15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:37:04", + "last_run": "2026-07-20T15:35:16", "profile": "position-analyst" }, { @@ -2528,7 +2566,7 @@ "script": "market_watch.py", "schedule": "*/10 9-11,13-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:50:31", + "last_run": "2026-07-20T15:50:32", "profile": "position-analyst" }, { @@ -2536,8 +2574,8 @@ "type": "no_agent", "script": "market_watch.py", "schedule": "0,30 9-16 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T16:30:30", + "status": "error", + "last_run": "2026-07-20T16:32:25", "profile": "default" }, { @@ -2545,8 +2583,8 @@ "type": "LLM", "script": null, "schedule": "0 16 * * 1-5", - "status": "error", - "last_run": "2026-07-16T16:04:39", + "status": "ok", + "last_run": "2026-07-20T16:09:40", "profile": "position-analyst" }, { @@ -2554,8 +2592,8 @@ "type": "no_agent", "script": "advice_reconciliation.py", "schedule": "0 20 * * 6", - "status": "ok", - "last_run": "2026-07-11T20:00:52", + "status": "error", + "last_run": "2026-07-18T20:00:06", "profile": "position-analyst" }, { @@ -2563,8 +2601,8 @@ "type": "no_agent", "script": "preflight_verify.py", "schedule": "30 8 * * 1-5", - "status": "error", - "last_run": "2026-07-17T08:31:08", + "status": "ok", + "last_run": "2026-07-20T08:30:35", "profile": "position-analyst" }, { @@ -2576,15 +2614,6 @@ "last_run": "2026-07-17T09:49:52", "profile": "position-analyst" }, - { - "name": "批量补全九维分析-一次性", - "type": "no_agent", - "script": "batch_reassess.py", - "schedule": "once at 2026-07-09 23:00", - "status": null, - "last_run": "None", - "profile": "position-analyst" - }, { "name": "持仓情报-盘后", "type": "LLM", @@ -2609,7 +2638,7 @@ "script": "sync_dashboard.py", "schedule": "55 8 * * *", "status": "ok", - "last_run": "2026-07-17T08:55:29", + "last_run": "2026-07-20T08:55:46", "profile": "default" }, { @@ -2617,8 +2646,8 @@ "type": "no_agent", "script": "data_governance.py", "schedule": "0 10 * * 6", - "status": "ok", - "last_run": "2026-07-11T10:00:53", + "status": "error", + "last_run": "2026-07-18T10:00:16", "profile": "position-analyst" }, { @@ -2636,7 +2665,7 @@ "script": null, "schedule": "0 22 * * *", "status": "error", - "last_run": "2026-07-17T22:09:26", + "last_run": "2026-07-19T22:09:19", "profile": "default" }, { @@ -2645,7 +2674,7 @@ "script": "intraday_health_check.py", "schedule": "*/15 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:45:13", + "last_run": "2026-07-20T15:45:12", "profile": "position-analyst" }, { @@ -2653,8 +2682,8 @@ "type": "no_agent", "script": "premarket_full_review.py", "schedule": "10 8 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T08:11:38", + "status": "error", + "last_run": "2026-07-20T08:12:09", "profile": "position-analyst" }, { @@ -2662,8 +2691,8 @@ "type": "LLM", "script": null, "schedule": "30 8 * * 1-5", - "status": "error", - "last_run": "2026-07-17T08:34:32", + "status": "ok", + "last_run": "2026-07-20T08:43:15", "profile": "default" }, { @@ -2671,8 +2700,8 @@ "type": "LLM", "script": null, "schedule": "0 22 * * 0", - "status": "ok", - "last_run": "2026-07-05T22:05:42", + "status": "error", + "last_run": "2026-07-19T22:04:59", "profile": "default" }, { @@ -2680,8 +2709,8 @@ "type": "no_agent", "script": "market_insight.py", "schedule": "35 15 * * 1-5", - "status": "ok", - "last_run": "2026-07-17T15:36:56", + "status": "error", + "last_run": "2026-07-20T15:35:06", "profile": "position-analyst" }, { @@ -2690,7 +2719,7 @@ "script": null, "schedule": "0 22 * * *", "status": "error", - "last_run": "2026-07-17T22:05:11", + "last_run": "2026-07-19T22:04:55", "profile": "default" }, { @@ -2699,7 +2728,7 @@ "script": "hardcode_scanner.py", "schedule": "25 17 * * 1-5", "status": "ok", - "last_run": "2026-07-17T17:26:31", + "last_run": "2026-07-20T17:26:10", "profile": "position-analyst" }, { @@ -2725,8 +2754,8 @@ "type": "no_agent", "script": "strategy_evaluator.py", "schedule": "0 21 * * 6", - "status": "ok", - "last_run": "2026-07-11T21:00:58", + "status": "error", + "last_run": "2026-07-18T21:00:18", "profile": "position-analyst" }, { @@ -2744,7 +2773,7 @@ "script": "review_needed_watchdog.py", "schedule": "0,30 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:30:40", + "last_run": "2026-07-20T15:30:59", "profile": "position-analyst" }, { @@ -2753,7 +2782,7 @@ "script": "system_health_check.py", "schedule": "0 9 * * 1-5", "status": "ok", - "last_run": "2026-07-17T10:34:47", + "last_run": "2026-07-20T10:38:16", "profile": "position-analyst" }, { @@ -2771,7 +2800,7 @@ "script": "self_todo_executor.py", "schedule": "*/10 8-22 * * 1-5", "status": "ok", - "last_run": "2026-07-17T22:40:05", + "last_run": "2026-07-20T17:20:45", "profile": "position-analyst" }, { @@ -2780,7 +2809,7 @@ "script": "relay_knowledge_report.py", "schedule": "10 22 * * *", "status": "ok", - "last_run": "2026-07-17T22:10:53", + "last_run": "2026-07-19T22:10:37", "profile": "default" }, { @@ -2788,8 +2817,8 @@ "type": "no_agent", "script": "per_stock_reassess.py", "schedule": "0 12,15 * * 1-5", - "status": "error", - "last_run": "2026-07-17T15:03:07", + "status": "ok", + "last_run": "2026-07-20T15:02:36", "profile": "position-analyst" }, { @@ -2797,8 +2826,8 @@ "type": "LLM", "script": null, "schedule": "0 20 * * 6", - "status": "ok", - "last_run": "2026-07-04T20:13:24", + "status": "error", + "last_run": "2026-07-18T20:04:35", "profile": "default" }, { @@ -2806,8 +2835,8 @@ "type": "no_agent", "script": "stale_detector.py", "schedule": "30 10 * * 0,6", - "status": "ok", - "last_run": "2026-07-12T10:30:57", + "status": "error", + "last_run": "2026-07-19T10:30:14", "profile": "position-analyst" }, { @@ -2816,7 +2845,7 @@ "script": "clean_watchlist.py", "schedule": "5 9 * * 1-5", "status": "ok", - "last_run": "2026-07-17T09:05:03", + "last_run": "2026-07-20T09:06:12", "profile": "position-analyst" }, { @@ -2825,7 +2854,7 @@ "script": "memory_guardian.py", "schedule": "0 7 * * *", "status": "ok", - "last_run": "2026-07-17T07:00:47", + "last_run": "2026-07-20T07:02:49", "profile": "position-analyst" }, { @@ -2834,7 +2863,7 @@ "script": "memory_guardian.py", "schedule": "0 7 * * *", "status": "error", - "last_run": "2026-07-17T07:02:58", + "last_run": "2026-07-20T07:02:09", "profile": "default" }, { @@ -2843,7 +2872,7 @@ "script": "capital_flow_collector.py", "schedule": "1,31 9-15 * * 1-5", "status": "ok", - "last_run": "2026-07-17T15:31:44", + "last_run": "2026-07-20T15:32:20", "profile": "position-analyst" }, { @@ -2852,7 +2881,7 @@ "script": "divergence_detector.py", "schedule": "*/30 8-15 * * *", "status": "ok", - "last_run": "2026-07-17T15:30:42", + "last_run": "2026-07-20T15:31:01", "profile": "position-analyst" }, { @@ -2860,8 +2889,8 @@ "type": "no_agent", "script": "divergence_detector.py", "schedule": "30 8-16/2 * * 0,6", - "status": "ok", - "last_run": "2026-07-12T16:30:18", + "status": "error", + "last_run": "2026-07-19T16:30:12", "profile": "position-analyst" }, { @@ -2870,7 +2899,7 @@ "script": "verify_reassess_pipeline.py", "schedule": "*/30 9-16 * * 1-5", "status": "ok", - "last_run": "2026-07-17T16:31:06", + "last_run": "2026-07-20T16:30:56", "profile": "position-analyst" }, { @@ -2878,9 +2907,46 @@ "type": "LLM", "script": null, "schedule": "28 9 * * 1-5", - "status": "error", - "last_run": "2026-07-17T09:33:09", + "status": "ok", + "last_run": "2026-07-20T09:54:17", "profile": "default" } + ], + "db_freshness": [ + { + "table": "mtf_cache", + "label": "多周期均线缓存", + "last_record": "07-20 17:08", + "age_hours": 0.4, + "warn": false + }, + { + "table": "macro_context_log", + "label": "宏观上下文", + "last_record": "07-20 15:31", + "age_hours": 2.0, + "warn": false + }, + { + "table": "market_snapshots", + "label": "市场快照", + "last_record": "07-20 15:30", + "age_hours": 2.0, + "warn": false + }, + { + "table": "live_prices", + "label": "实时价格", + "last_record": "07-20 17:05", + "age_hours": 0.4, + "warn": false + }, + { + "table": "price_events.json", + "label": "价格事件", + "last_record": "07-20 17:06", + "age_hours": 0.4, + "warn": false + } ] } \ No newline at end of file