Files
MoFin/deploy/profile-scripts/functional_health_check.py
T

303 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""functional_health_check.py — L1 功能健康检查
判据不是"进程活着",而是"功能是否达成":每个核心模块检查其
**输出物的新鲜度和有效性**。输出物不新鲜 = 功能未达成 = 异常。
频率:交易时段每 15 分钟(cron),输出 gateway/logs/functional_health.json。
责任矩阵 L1。修复由 L3 self_repair 读取本报告执行。
"""
import os, sys, json, sqlite3, subprocess
from datetime import datetime, timedelta
sys.path.insert(0, '/home/hmo/MoFin')
DB = '/home/hmo/MoFin/data/mofin.db'
OUT = '/home/hmo/MoFin/gateway/logs/functional_health.json'
# ── 功能注册表:模块 → 功能判据 ──────────────────────────────
# type:
# db_freshness: DB 表 MAX(col) 距今不超过 max_agetrading=交易时段才检查, daily=每日, always=总是)
# file_freshness: 文件 mtime 距今不超过 max_age
# bot_activity: XMPP bot 活动检查(journal
# agent_log: gateway agent.log 最近有成功 LLM 调用
# soft=True: 无输出可能属正常(如区间未触发),降级为 warn 而非 fail
REGISTRY = [
{"module": "price_monitor", "function": "实时价格写入DB(live_prices)",
"check": {"type": "db_freshness", "table": "live_prices", "col": "updated_at",
"max_age_min": 6, "when": "trading"},
"repair": {"action": "rerun_script", "script": "price_monitor.py"}},
{"module": "market_watch", "function": "市场快照采集(market_snapshots)",
"check": {"type": "db_freshness", "table": "market_snapshots", "col": "created_at",
"max_age_min": 40, "when": "trading"},
"repair": {"action": "rerun_script", "script": "market_watch.py"}},
{"module": "mtf_cache", "function": "多周期均线缓存刷新(mtf_cache)",
"check": {"type": "db_freshness", "table": "mtf_cache", "col": "updated_at",
"max_age_min": 75, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_mtf_cache.py"}},
{"module": "macro_context", "function": "宏观上下文刷新(macro_context_log)",
"check": {"type": "db_freshness", "table": "macro_context_log", "col": "created_at",
"max_age_min": 45, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_macro_context.py"}},
{"module": "health_collector", "function": "健康数据采集(mofin_health.json)",
"check": {"type": "file_freshness", "path": "/home/hmo/web-dashboard/static/mofin_health.json",
"max_age_min": 20, "when": "trading"},
"repair": {"action": "rerun_script", "script": "mofin_health.py"}},
{"module": "premarket", "function": "盘前全量重评(premarket summary)",
"check": {"type": "file_freshness", "path": "/tmp/mofin_premarket/summary.json",
"max_age_h": 26, "when": "daily"},
"repair": {"action": "rerun_script", "script": "premarket_full_review.py"}},
{"module": "reassess_daily", "function": "每日12维重评完成度(持仓当日覆盖+分析存在率)",
"check": {"type": "reassess_daily", "when": "daily"},
"repair": {"action": "rerun_script", "script": "batch_reassess.py --type holding --today"}},
{"module": "gateway_llm", "function": "LLM调用链可用(gateway agent.log)",
"check": {"type": "agent_log", "max_age_min": 30, "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "sense_ocr", "function": "识图服务(SenseNova OCR配置+API可达)",
"check": {"type": "ocr_health", "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "xmpp_bot", "function": "XMPP消息收发(bot journal)",
"check": {"type": "bot_activity", "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "cron_engine", "function": "cron调度引擎本身(有job在最近10min运行)",
"check": {"type": "cron_engine", "max_age_min": 12, "when": "trading"},
"repair": {"action": "llm_diagnose"}},
]
def is_trading_now(now):
return now.weekday() < 5 and 9 <= now.hour <= 16
def check_db_freshness(conn, chk, now):
try:
row = conn.execute(f"SELECT MAX({chk['col']}) FROM {chk['table']}").fetchone()
if not row or not row[0]:
return "fail", f"表 {chk['table']} 无数据"
last = datetime.fromisoformat(str(row[0]).replace("Z", ""))
age_min = (now - last).total_seconds() / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{chk['table']} 最新记录 {last.strftime('%m-%d %H:%M')}{age_min/60:.1f}h前,阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
except Exception as e:
return "fail", f"查询失败: {str(e)[:80]}"
def check_file_freshness(chk, now):
p = chk["path"]
if not os.path.exists(p):
return "fail", f"文件不存在: {p}"
age_min = (now.timestamp() - os.path.getmtime(p)) / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{os.path.basename(p)} {age_min/60:.1f}h 未更新(阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
def check_agent_log(chk, now):
try:
sys.path.insert(0, '/home/hmo/MoFin')
from xmpp_logger import _scan_agent_log
r = _scan_agent_log(now.timestamp(), "zhiwei")
if r["status"] == "ok":
age = r.get("age_sec", -1)
if age > chk["max_age_min"] * 60:
return "warn", f"最近成功LLM调用在 {age//60}min 前(无新流量,可能正常)"
return "ok", f"latency={r.get('latency')}"
return "fail", f"agent.log 最近调用失败: {r.get('error','?')[:100]}"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_bot_activity(chk, now):
try:
# 第一判据:服务当前是否 active(权威"现在在不在跑"
r2 = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei"],
capture_output=True, text=True, timeout=5)
if r2.stdout.strip() != "active":
return "fail", "bot 服务非 active(已停止)"
# 第二判据:最近 journal 是否处于断线循环
r = subprocess.run(
["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "30 min ago", "-o", "cat"],
capture_output=True, timeout=8, text=True)
lines = r.stdout
if "连接超时" in lines and "就绪" not in lines:
return "fail", "bot 处于断线重连循环"
if "XMPP 就绪" in lines or "已发送" in lines or "收到" in lines:
return "ok", "bot 活动正常"
return "ok", "bot 在线空闲(无新消息)"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_ocr_health(chk, now):
"""识图服务健康:OCR 配置存在 + SenseNova API 可达(TCP 443)。
不发真实 OCR 请求(省钱),真实端到端验证走 K 测试。"""
import json as _json, socket
# 1. OCR 配置存在且含 key
cfg_path = '/home/hmo/.config/mofin/ocr_config.json'
if not os.path.exists(cfg_path):
return "fail", "OCR 配置文件不存在: /home/hmo/.config/mofin/ocr_config.json"
try:
cfg = _json.load(open(cfg_path))
if not cfg.get('key') or not cfg.get('base_url'):
return "fail", "OCR 配置缺 key 或 base_url"
except Exception as e:
return "fail", f"OCR 配置解析失败: {str(e)[:60]}"
# 2. SenseNova API TCP 可达
try:
host = cfg['base_url'].split('//')[1].split('/')[0].split(':')[0]
port = int(cfg['base_url'].split('//')[1].split('/')[0].split(':')[1]) if ':' in cfg['base_url'].split('//')[1].split('/')[0] else 443
s = socket.create_connection((host, port), timeout=5)
s.close()
except Exception as e:
return "fail", f"SenseNova API 不可达 ({host}): {str(e)[:60]}"
return "ok", f"配置 OK + {host}:{port} 可达"
def check_cron_engine(chk, now):
"""cron 引擎:最近 max_age_min 内是否有任何 job 运行过"""
try:
d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'))
jobs = d if isinstance(d, list) else d.get('jobs', [])
latest = None
for j in jobs:
lr = j.get('last_run_at')
if lr:
try:
t = datetime.fromisoformat(lr.replace('Z', '+00:00')).replace(tzinfo=None)
if latest is None or t > latest:
latest = t
except Exception:
pass
if not latest:
return "fail", "所有 job 均无运行记录"
age_min = (now - latest).total_seconds() / 60
if age_min > chk["max_age_min"]:
return "fail", f"最近 job 运行在 {age_min:.0f}min 前,调度引擎疑似停摆"
return "ok", f"最近 job 运行于 {age_min:.0f}min 前"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_reassess_daily(conn, now):
"""每日12维重评完成度:
- 持仓:当日已重评比例(交易日13:00后应≈100%,此前按上一交易日)+ 有分析比例
- 自选:近24h重评比例(补评管道是否活着)
阈值:持仓当日覆盖<50% 或 分析存在率<70% → fail<90% → warn
"""
try:
today = now.strftime("%Y-%m-%d")
rows = conn.execute(
"SELECT decision_type, reassessed_at, full_analysis FROM holding_strategies WHERE status='active'").fetchall()
h_total = h_today = h_fa = w_total = w_24h = 0
cutoff = now - timedelta(hours=24)
for dt, ra, fa in rows:
fa_ok = bool(fa and str(fa).strip())
ra_dt = None
if ra:
try:
ra_dt = datetime.fromisoformat(str(ra)[:19])
except Exception:
pass
if dt == '持仓策略':
h_total += 1
if fa_ok:
h_fa += 1
if ra_dt and ra_dt.strftime("%Y-%m-%d") == today:
h_today += 1
else:
w_total += 1
if ra_dt and ra_dt >= cutoff:
w_24h += 1
if h_total == 0:
return "fail", "holding_strategies 无持仓数据"
h_cov = h_today / h_total
h_fa_rate = h_fa / h_total
w_cov = w_24h / w_total if w_total else 1
detail = (f"持仓 今日重评{h_today}/{h_total}({h_cov:.0%}) 有分析{h_fa}/{h_total}({h_fa_rate:.0%}) | "
f"自选 24h重评{w_24h}/{w_total}({w_cov:.0%})")
# 12:35 前补评窗口未完成属正常,降级 warn
grace = now.hour < 13 or now.weekday() >= 5
if h_fa_rate < 0.7:
return "fail", f"分析存在率过低: {detail}"
if h_cov < 0.5 and not grace:
return "fail", f"今日持仓重评覆盖不足: {detail}"
if h_cov < 0.9 and not grace:
return "warn", f"覆盖不完整: {detail}"
if grace and (h_cov < 0.9 or w_cov < 0.2):
return "warn", f"补评窗口中: {detail}"
return "ok", detail
except Exception as e:
return "fail", f"检查异常: {e}"
def main():
now = datetime.now()
trading = is_trading_now(now)
results = []
conn = sqlite3.connect(DB, timeout=10)
for item in REGISTRY:
chk = item["check"]
when = chk.get("when", "always")
if when == "trading" and not trading:
results.append({"module": item["module"], "function": item["function"],
"status": "skip", "reason": "非交易时段"})
continue
if when == "daily":
# 每日类:只 fail 不 warn,且非交易日放宽到 72h
if now.weekday() >= 5:
chk = dict(chk)
chk["max_age_h"] = 72
t = chk["type"]
if t == "db_freshness":
status, reason = check_db_freshness(conn, chk, now)
elif t == "file_freshness":
status, reason = check_file_freshness(chk, now)
elif t == "agent_log":
status, reason = check_agent_log(chk, now)
elif t == "bot_activity":
status, reason = check_bot_activity(chk, now)
elif t == "ocr_health":
status, reason = check_ocr_health(chk, now)
elif t == "cron_engine":
status, reason = check_cron_engine(chk, now)
elif t == "reassess_daily":
status, reason = check_reassess_daily(conn, now)
else:
status, reason = "fail", f"未知检查类型 {t}"
results.append({"module": item["module"], "function": item["function"],
"status": status, "reason": reason,
"repair": item.get("repair")})
conn.close()
fails = [r for r in results if r["status"] == "fail"]
warns = [r for r in results if r["status"] == "warn"]
report = {
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"trading_hours": trading,
"summary": {"total": len(results), "ok": sum(1 for r in results if r["status"] == "ok"),
"warn": len(warns), "fail": len(fails),
"skip": sum(1 for r in results if r["status"] == "skip")},
"status": "fail" if fails else ("warn" if warns else "ok"),
"checks": results,
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"功能健康: {report['summary']} status={report['status']}")
for r in results:
if r["status"] in ("fail", "warn"):
print(f" {r['status'].upper()} {r['module']}: {r['reason']}")
if __name__ == "__main__":
main()