From d04e7664ee1de6537654c824bd6e8d094946507f Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 22 Jul 2026 08:41:29 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20L2=E5=8D=AB=E7=94=9F=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=201=20=E4=B8=AA=E5=BD=B1=E5=AD=90/=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=96=87=E4=BB=B6=EF=BC=88archive/202607-auto?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- archive/202607-auto/zz_fake_orphan_test.py | 1 + scripts/batch_reassess.py | 554 +++++++++++++ scripts/candidate_filter.py | 354 ++++++++ scripts/fix_gateway_port.py | 93 +++ scripts/morning_health_check.py | 893 +++++++++++++++++++++ scripts/per_stock_reassess.py | 822 +++++++++++++++++++ scripts/price_monitor.py | 781 ++++++++++++++++++ scripts/stale_detector.py | 397 +++++++++ scripts/verify_reassess_pipeline.py | 206 +++++ 9 files changed, 4101 insertions(+) create mode 100644 archive/202607-auto/zz_fake_orphan_test.py create mode 100644 scripts/batch_reassess.py create mode 100644 scripts/candidate_filter.py create mode 100644 scripts/fix_gateway_port.py create mode 100644 scripts/morning_health_check.py create mode 100644 scripts/per_stock_reassess.py create mode 100644 scripts/price_monitor.py create mode 100644 scripts/stale_detector.py create mode 100644 scripts/verify_reassess_pipeline.py diff --git a/archive/202607-auto/zz_fake_orphan_test.py b/archive/202607-auto/zz_fake_orphan_test.py new file mode 100644 index 00000000..f930a53c --- /dev/null +++ b/archive/202607-auto/zz_fake_orphan_test.py @@ -0,0 +1 @@ +# fake orphan for auto-archive test diff --git a/scripts/batch_reassess.py b/scripts/batch_reassess.py new file mode 100644 index 00000000..450eee54 --- /dev/null +++ b/scripts/batch_reassess.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +"""batch_reassess.py — 批量补全12维(九维矩阵)LLM分析(逐只处理,间隔防限流) + +用法: + python3 batch_reassess.py # 所有缺分析/过期的 active 策略 + python3 batch_reassess.py --type holding # 只处理持仓策略 + python3 batch_reassess.py --type watchlist # 只处理自选策略 + python3 batch_reassess.py --type holding --today # 持仓每日刷新(今早未评过的强制重评) + python3 batch_reassess.py --code XXXXXX # 单只 + +流程:收集最新数据 → 调LLM(gateway)写12维分析+策略 → 保存到DB +""" +import sys, json, subprocess, sqlite3, re, time, os +from datetime import datetime + +# ── 共享 LLM 客户端 + DB 工具(profile-scripts 硬链到同目录)── +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, "/home/hmo/MoFin") +from llm_client import call_llm, REASSESS_MODEL, FALLBACK_MODEL, gateway_alive, ocg_alive +from mofin_db import snapshot_strategy_history, sync_recommend_tag + +DB = "/home/hmo/MoFin/data/mofin.db" +COOLDOWN_HOURS = 1 +STALE_HOURS = 20 # 分析超过20小时视为过期,需要重评 + +def has_llm_analysis(code): + """检查是否为LLM生成的12维分析(>500字)""" + conn = sqlite3.connect(DB) + r = conn.execute("SELECT LENGTH(full_analysis) FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + conn.close() + return r and r[0] and r[0] > 500 + +def in_cooldown(code): + """冷却期检查""" + conn = sqlite3.connect(DB) + r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + conn.close() + if not r or not r[0]: + return False + try: + last = datetime.fromisoformat(r[0]) + diff = (datetime.now() - last).total_seconds() / 3600 + return diff < COOLDOWN_HOURS + except: + return False + +def analysis_stale(code, force_today=False): + """分析是否过期(>STALE_HOURS 或 force_today 时今早4点前未重评)""" + conn = sqlite3.connect(DB) + r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + conn.close() + if not r or not r[0]: + return True + try: + last = datetime.fromisoformat(r[0]) + if force_today: + today4am = datetime.now().replace(hour=4, minute=0, second=0, microsecond=0) + return last < today4am + return (datetime.now() - last).total_seconds() / 3600 > STALE_HOURS + except: + return True + +def get_portfolio(): + """从 portfolio_summary 读实时现金/总资产(不再硬编码)""" + try: + conn = sqlite3.connect(DB) + r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone() + conn.close() + if r and r[1]: + return int(r[0] or 0), int(r[1]) + except Exception: + pass + return 0, 0 + +def collect_data(code): + """收集最新数据(含完整策略原文)""" + data = {"code": code} + + # 从DB读策略(含 full_analysis / changelog_json / position_advice) + conn = sqlite3.connect(DB) + r = conn.execute("SELECT name, entry_low, entry_high, stop_loss, take_profit, timing_signal, action, rr_ratio, tech_snapshot, sector_context, stock_category, full_analysis, changelog_json, reassessed_at, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if r: + data["name"] = r[0] + data["entry_low"] = r[1] or 0 + data["entry_high"] = r[2] or 0 + data["stop_loss"] = r[3] or 0 + data["take_profit"] = r[4] or 0 + data["timing_signal"] = r[5] or "" + data["action"] = r[6] or "" + data["rr_ratio"] = r[7] or 0 + data["tech_snapshot"] = r[8] or "" + data["sector_context"] = r[9] or "" + data["stock_category"] = r[10] or "" + data["full_analysis"] = r[11] or "" + data["changelog_json"] = r[12] or "" + data["reassessed_at"] = r[13] or "" + data["position_advice"] = r[14] or "" + conn.close() + + # 从腾讯API拉最新价和基本面 + # 代码前缀:5位=港股(hk),6/9开头=沪(sh),其他=深(sz) + _c = str(code) + if len(_c) == 5: + prefix = "hk" + elif _c.startswith(("6", "9")): + prefix = "sh" + else: + prefix = "sz" + try: + r = subprocess.run(["curl", "-s", f"http://qt.gtimg.cn/q={prefix}{code}"], capture_output=True, timeout=10) + parts = r.stdout.decode("gbk", errors="ignore").split("~") + data["price"] = float(parts[3]) if len(parts) > 3 and parts[3] else 0 + data["pe"] = parts[39] if len(parts) > 39 and parts[39] else "" + data["mcap"] = parts[44] if len(parts) > 44 and parts[44] else "" + data["change_pct"] = parts[32] if len(parts) > 32 and parts[32] else "0" + except: + data["price"] = 0 + + # 大盘 + try: + conn = sqlite3.connect(DB) + mr = conn.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if mr and mr[0]: + s = json.loads(mr[0]) + data["macro"] = s.get("description", "大盘震荡") + conn.close() + except: + data["macro"] = "大盘震荡" + + return data + +def build_prompt(data): + """构建LLM prompt,先审阅原策略再结合实时数据输出修改判断+九维矩阵分析""" + cash, total = get_portfolio() + if not total: + cash, total = 241330, 929727 # 兜底(DB读不到时) + + # 拉取资金流数据 + _flow_note = "暂无资金流数据" + try: + import sqlite3 as _sq, json as _j + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + _fr = _db.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + if _fr and _fr[0]: + _fc = _j.loads(_fr[0]) + _stocks = _fc.get("stocks", {}) + _s = _stocks.get(data['code'], {}) + if _s and _s.get("analysis"): + _a = _s["analysis"] + _net = _a.get("net_flow", 0) + _main = _a.get("main_force", 0) + _retail = _a.get("retail_flow", 0) + _trend = _a.get("trend", "中性") + _flow_note = f"净流入{_net:.0f}万 主力{_main:.0f}万 散户{_retail:.0f}万 趋势{_trend}" + _db.close() + except: + pass + + # 拉取近期消息面 + _news_note = "暂无近期消息" + try: + import sqlite3 as _sq + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + _nr = _db.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 3", + (data['code'], f'%{data.get("name","")[:4]}%') + ).fetchall() + if _nr: + _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr]) + _db.close() + except: + pass + + # ── 构建【原策略全文】section ── + _params_parts = [] + if data.get('action'): _params_parts.append(f"当前策略: {data['action']}") + if data.get('timing_signal'): _params_parts.append(f"信号: {data['timing_signal']}") + if data.get('entry_low') or data.get('entry_high'): + _params_parts.append(f"买入区间: {data.get('entry_low',0)}~{data.get('entry_high',0)}") + if data.get('stop_loss'): _params_parts.append(f"止损: {data['stop_loss']}") + if data.get('take_profit'): _params_parts.append(f"止盈: {data['take_profit']}") + if data.get('position_advice'): _params_parts.append(f"仓位: {data['position_advice']}") + _params_str = " | ".join(_params_parts) if _params_parts else "无策略参数" + + # 最近3条变更记录 + _changelog_str = "无变更记录" + try: + _cl_raw = data.get('changelog_json', '') + if _cl_raw: + _cl = json.loads(_cl_raw) if isinstance(_cl_raw, str) else _cl_raw + if isinstance(_cl, list) and _cl: + _recent = _cl[-3:] if len(_cl) > 3 else _cl + _cl_lines = [] + for i, c in enumerate(_recent): + _act = c.get('action', c.get('reason', '')) if isinstance(c, dict) else str(c) + _ts = c.get('timestamp', '') if isinstance(c, dict) else '' + _cl_lines.append(f" {i+1}. {_ts[:16]} {_act[:80]}") + if _cl_lines: + _changelog_str = "\n".join(_cl_lines) + except: + pass + + # 完整分析原文(不截断) + _full_analysis = data.get('full_analysis', '') or '' + _fa_display = _full_analysis if _full_analysis else '(首次分析,无历史)' + + _orig_strategy_section = f"""当前策略参数: {_params_str} + +变更记录(最近3条): +{_changelog_str} + +完整分析原文: +{_fa_display}""" + + return f"""你是一个资深A股分析师。请先审阅以下【原策略全文】,判断是否需要修改策略,然后做出完整的九维矩阵分析。 + +【原策略全文】 +{_orig_strategy_section} + +── 以上是已有的策略,以下是当前实时数据,请结合两者做出判断 ── + +⚠️ 重要:以下9个维度不是独立分析的,你必须交叉对比后给出综合结论。 +例如:如果消息面利好但资金流在流出,说明利好可能是出货;如果基本面强但技术面破位,说明估值可能还没到底。 + +当前数据(以下数据均来自实时API,每条标注时间窗口,禁止使用模型内部训练数据): +大盘:{data.get('macro','震荡')}(当日实时) +最新价:{data.get('price',0)} 涨跌:{data.get('change_pct','0')}%(当日实时) +PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 +行业:{data.get('sector_context','?')}(当日实时) +技术面:{data.get('tech_snapshot','')[:300]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) +资金流:{_flow_note}(近5日累计) +消息面:{_news_note}(最近3条,自动标注抓取时间) +当前信号:{data.get('timing_signal','?')} 分类:{data.get('stock_category','?')} + +我的总资产={total}元,可用现金={cash}元。 + +请严格按以下格式输出(注意节标题不可省略): + +【维持或修改】明确二选一判断:维持原策略 / 需要修改策略 +【修改点及理由】 +如果维持原策略 → 写"无需修改" +如果需要修改 → 逐条列出(每条格式:"- 修改点名称:理由说明") +【最终新策略】 +用自然语言输出完整的最终策略全文(200-400字),自包含核心交易逻辑、买入区间价格、止损价、止盈价、仓位比例、风险提示。 +⚠️ 本段不要使用【综合结论】【买入区间】等标签——用自然语言描述即可。 + +【交叉分析】用2-3句话说明哪些维度出现矛盾/共振,最关键的信号是什么 +① 大盘×基本面 [一句话,说明矛盾关系] +② 大盘×消息面 [一句话] +③ 大盘×技术面 [一句话] +④ 大盘×资金面 [一句话] +⑤ 行业×基本面 [一句话] +⑥ 行业×消息面 [一句话] +⑦ 行业×技术面 [一句话] +⑧ 行业×资金面 [一句话] +⑨ 个股×基本面 [一句话] +⑩ 个股×消息面 [一句话] +⑪ 个股×技术面 [一句话] +⑫ 个股×资金面 [一句话] + +【综合结论】(买入/关注/观望/卖出) +【操作建议】具体操作建议 +【买入区间】最低价~最高价 +【建议止损】数字 +【建议止盈】数字 + +【建议仓位】⚠️不可省略。综合结论非"买入"时写"不新建仓";为"买入"时按以下公式: +基础仓位按RR确定:RR<1.5→不推荐,RR1.5~3→8%,RR3~5→12%,RR5+→15% +大盘偏弱×0.8,大盘偏强×1.15 +蓝筹/白马×1.2,成长×0.85,题材/短线×0.6 +最终仓位范围:5%~20% +同时考虑:现金{cash}元足够买多少手。 +输出格式:"X%(理由:一句话说明为什么这个仓位)" + +⚠️ 输出纪律(必须遵守): +1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 +2. 禁止输出 或任何 XML/JSON/代码块 +3. 所有【】节标题一个都不能少""" +def parse_response(text): + """从LLM回复中提取策略参数""" + result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""} + + # 信号 + sl = [l for l in text.split("\n") if "综合结论" in l] + if sl: + for kw in ["买入","关注","观望","卖出"]: + if kw in sl[0]: + result["signal"] = kw + break + + # 买入区间 + zl = [l for l in text.split("\n") if "买入区间" in l] + if zl: + nums = re.findall(r'[\d.]+', zl[0]) + if len(nums) >= 2: + result["entry_low"] = float(nums[0]) + result["entry_high"] = float(nums[1]) + + # 止损 + for l in text.split("\n"): + if "建议止损" in l: + nums = re.findall(r'[\d.]+', l) + if nums: result["stop_loss"] = float(nums[0]) + + # 止盈 + for l in text.split("\n"): + if "建议止盈" in l: + nums = re.findall(r'[\d.]+', l) + if nums: result["take_profit"] = float(nums[0]) + + # 仓位:只有买入信号才需要,提取百分比数字 + result["position"] = "" + if result["signal"] == "买入": + for l in text.split("\n"): + if "建议仓位" in l: + nums = re.findall(r'[\d.]+', l) + for n in nums: + f = float(n) + if 1 <= f <= 30: # 合理的仓位范围 + result["position"] = f"{f:.0f}%" + break + break + + return result + +def save_result(code, full_text, parsed): + """保存LLM结果到DB(先快照再UPDATE)。空分析拒绝写入。""" + if not (full_text or "").strip(): + print(f" \u274c 拒绝写入空分析(LLM输出为空,保护已有数据)") + return + conn = sqlite3.connect(DB) + now = datetime.now().isoformat() + + # ── 修改前快照 ── + snapshot_strategy_history(conn, code, 'batch_12d') + + updates = ["full_analysis=?", "reassessed_at=?"] + params = [full_text, now] + + if parsed["signal"]: + updates.append("timing_signal=?") + params.append(parsed["signal"]) + # 区间写入门禁:上下沿都必须为正且 下沿<上沿<下沿x3,否则视为解析错误整体跳过 + # (防 214.68~2.52 类解析污染,与 GATE_ZONE_SANITY 同级防护) + _el, _eh = parsed["entry_low"], parsed["entry_high"] + if _el > 0 and _eh > _el and _eh < _el * 3: + updates.append("entry_low=?") + params.append(_el) + updates.append("entry_high=?") + params.append(_eh) + elif _el > 0 or _eh > 0: + print(f" ⚠️ 买入区解析异常({_el}~{_eh}),跳过区间写入(保留原值)", flush=True) + # 止损/止盈一致性门禁:损>0 时必须在区间下沿之下(0.5x~1.0x),盈>0 时必须在区间上沿之上 + _sl, _tp = parsed["stop_loss"], parsed["take_profit"] + if _sl > 0 and (not _el or _sl < _el) and (not _tp or _sl < _tp): + updates.append("stop_loss=?") + params.append(_sl) + elif _sl > 0: + print(f" ⚠️ 止损{_sl}与区间/止盈不一致,跳过写入(保留原值)", flush=True) + if _tp > 0 and (not _eh or _tp > _eh) and (not _sl or _tp > _sl): + updates.append("take_profit=?") + params.append(_tp) + elif _tp > 0: + print(f" ⚠️ 止盈{_tp}与区间/止损不一致,跳过写入(保留原值)", flush=True) + if parsed["position"]: + updates.append("position_advice=?") + params.append(parsed["position"]) + + params.append(code) + sql = f"UPDATE holding_strategies SET {', '.join(updates)} WHERE code=? AND status='active'" + conn.execute(sql, params) + conn.commit() + + # ── 推荐操作 tag 同步(与 XMPP 动作级信号同源)── + sync_recommend_tag(conn, code, parsed.get("signal", "")) + + # 买入信号→推XMPP通知(在conn close前执行)——推送质量门禁: + # 价格必须>0(live_prices实时价)、区间有效(下沿<上沿<下沿x3)、现价不超过上沿5%、 + # 损<下沿、盈>上沿、损在(0.5x~1.0x)现价内。任何一项不过 → 不推,只记日志。 + if parsed.get("signal") == "买入": + try: + _nr = conn.execute("SELECT name FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + _name = _nr[0] if _nr else code + _p = _lp[0] if _lp and _lp[0] else 0 + _el = parsed.get("entry_low", 0) + _eh = parsed.get("entry_high", 0) + _sl = parsed.get("stop_loss", 0) + _tp = parsed.get("take_profit", 0) + _pos = parsed.get("position", "") + _ok, _why = _validate_buy_alert(_p, _el, _eh, _sl, _tp) + if _ok: + _msg = f"📈 {_name}({code}) 价{_p}→12维分析生成买入信号!区间{_el}~{_eh} 损{_sl} 盈{_tp} 仓位{_pos}" + from alert_helper import notify as _notify, ACTION as _ACT + _notify("买入信号", _msg, _ACT) + print(f" \U0001f4e8 XMPP推送成功: {_msg[:60]}") + else: + print(f" ⚠️ 买入信号未过推送门禁({_why}),仅记日志不推送", flush=True) + except Exception as _e: + print(f" \u26a0\ufe0f XMPP推送失败: {_e}") + + conn.close() + + +def _validate_buy_alert(price, el, eh, sl, tp): + """买入信号推送门禁(垃圾信号不发)。 + 返回 (ok, reason)""" + if not price or price <= 0: + return False, f"无实时价格({price})" + if not (el > 0 and eh > el and eh < el * 3): + return False, f"区间无效({el}~{eh})" + if price > eh * 1.05: + return False, f"现价{price}高于区间上沿{eh}超5%(追高信号不推)" + if not (sl > 0 and sl < el and price * 0.5 <= sl <= price): + return False, f"止损{sl}不合理(需0.5x~1.0x现价且<下沿{el})" + if not (tp > eh and tp > sl): + return False, f"止盈{tp}需>上沿{eh}且>止损{sl}" + return True, "" + +def process_stock(code, force_today=False): + """处理单只股票""" + print(f"\n{'='*50}") + print(f"处理: {code}") + print(f"{'='*50}") + + if in_cooldown(code): + print(f" \u23ed 冷却期内,跳过") + return False + + # 有分析且未过期 \u2192 跳过(除非 force_today 且今早未评) + if has_llm_analysis(code) and not analysis_stale(code, force_today): + print(f" \u23ed 已有12维分析且未过期,跳过") + return False + + print(f" 收集数据...", flush=True) + data = collect_data(code) + if not data.get("price"): + print(f" \u26a0\ufe0f 无价格数据,跳过") + return False + + print(f" 调LLM生成九维分析...", flush=True) + prompt = build_prompt(data) + + # ── 使用共享 LLM 客户端(替代 curl subprocess)── + result = call_llm(prompt, model=REASSESS_MODEL, max_tokens=4096) + + if not result["ok"] or not (result.get("content") or "").strip(): + print(f" \u274c LLM调用失败或空输出: {result.get('error') or 'empty content'}") + return False + + full_text = result["content"] + print(f" \u2705 LLM返回({len(full_text)}字, {result['elapsed']:.1f}s, 尝试{result['attempts']}次)", flush=True) + + parsed = parse_response(full_text) + + # ── 截断保护:输出过短且无信号 = 低质输出,升级 pro 重试一次 ── + if not parsed.get("signal") and len(full_text) < 1500: + print(f" ⚠️ 输出截断({len(full_text)}字)且无信号,升级 {FALLBACK_MODEL} 重试...", flush=True) + result2 = call_llm(prompt, model=FALLBACK_MODEL, max_tokens=4096) + if result2["ok"] and len((result2.get("content") or "").strip()) > len(full_text): + full_text = result2["content"] + parsed = parse_response(full_text) + print(f" \u2705 升级后({len(full_text)}字)", flush=True) + + print(f" 信号={parsed['signal']} 区间={parsed['entry_low']}~{parsed['entry_high']} 损={parsed['stop_loss']} 盈={parsed['take_profit']} 仓位={parsed['position']}") + + save_result(code, full_text, parsed) + print(f" \u2705 已保存到DB") + return True + +def main(): + # ── 双通道预检:OCG直连 + hermes gateway 兜底,全挂才退出 ── + _ocg_ok = ocg_alive() + _gw_ok = gateway_alive() + if not _ocg_ok and not _gw_ok: + print("[FATAL] OCG上游与hermes gateway均不可用,退出") + sys.exit(1) + if not _ocg_ok: + print("[WARN] OCG直连不可用,将使用gateway兜底(agent运行时,较慢)") + if not _gw_ok: + print("[WARN] hermes gateway不可用,仅使用OCG直连") + + codes = [] + force_today = "--today" in sys.argv + dtype = None + if "--type" in sys.argv: + idx = sys.argv.index("--type") + dtype = sys.argv[idx + 1] # holding | watchlist | all + if "--code" in sys.argv: + idx = sys.argv.index("--code") + codes = [sys.argv[idx+1]] + else: + # 按类型筛选 active 策略 + type_map = {"holding": "持仓策略", "watchlist": "自选策略"} + conn = sqlite3.connect(DB) + if dtype in type_map: + rows = conn.execute( + "SELECT code FROM holding_strategies WHERE status='active' AND decision_type=? ORDER BY code", + (type_map[dtype],)).fetchall() + else: + rows = conn.execute( + "SELECT code FROM holding_strategies WHERE status='active' ORDER BY decision_type, code").fetchall() + conn.close() + codes = [r[0] for r in rows] + + print(f"待处理: {len(codes)}只 (type={dtype or 'all'}, force_today={force_today})") + + ok = 0 + fail = 0 + skip = 0 + failed_codes = [] + for i, code in enumerate(codes): + if has_llm_analysis(code) and not analysis_stale(code, force_today): + print(f" [{i+1}/{len(codes)}] \u23ed {code} 已有12维分析且未过期") + skip += 1 + continue + + print(f" [{i+1}/{len(codes)}] ", end="", flush=True) + if process_stock(code, force_today): + ok += 1 + else: + fail += 1 + failed_codes.append(code) + + # 间隔8秒(pro model较重但gateway可承受;retry逻辑吸收瞬断) + if i < len(codes) - 1: + print(f" 等待8秒...", flush=True) + time.sleep(8) + + # ── 失败二轮:主跑结束后休息 60s 让上游恢复,失败股整体重试一次 ── + # (凌晨上游空输出高发,二轮可救回大半;仍失败的留给下一轮调度) + if failed_codes: + print(f"\n{'='*50}") + print(f"失败二轮: {len(failed_codes)}只,休息60s后重试...") + time.sleep(60) + retry_ok = 0 + for code in failed_codes: + print(f" [retry] {code} ", end="", flush=True) + if process_stock(code, force_today): + retry_ok += 1 + ok += 1 + fail -= 1 + print(f" 等待8秒...", flush=True) + time.sleep(8) + print(f"失败二轮: {retry_ok}/{len(failed_codes)} 救回") + + print(f"\n{'='*50}") + print(f"完成: {ok}成功, {fail}失败, {skip}跳过") + print(f"{'='*50}") + +if __name__ == "__main__": + main() diff --git a/scripts/candidate_filter.py b/scripts/candidate_filter.py new file mode 100644 index 00000000..4a48c068 --- /dev/null +++ b/scripts/candidate_filter.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""candidate_filter.py — 候选股多级过滤管道 + +从 candidates 表读取未过滤的候选,逐级执行过滤: + Stage 2: 多日K线确认(量价连续性) + Stage 3: 技术位分析(MA位置) + Stage 4: 资金性质(大单流向) + Stage 5: 基本面(PE/PB/行业) + +用法: python3 candidate_filter.py [--stage 2|3|4|5] [--code XXXXXX] +""" +import sys, json, urllib.request, sqlite3, re, time +from pathlib import Path +from datetime import datetime + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +UA = "Mozilla/5.0" + +def get_conn(): + c = sqlite3.connect(str(DB_PATH), timeout=30) + c.execute("PRAGMA busy_timeout=30000") + return c + +def log_candidate(conn, code, stage, passed, detail): + """记录过滤日志""" + conn.execute( + "UPDATE candidates SET log = COALESCE(log, '[]')" + ) + # SQLite JSON操作 + existing = conn.execute("SELECT log FROM candidates WHERE code=?", (code,)).fetchone() + if existing and existing[0]: + try: + logs = json.loads(existing[0]) + except: + logs = [] + else: + logs = [] + logs.append({"stage": stage, "passed": passed, "detail": detail, "time": datetime.now().strftime("%m-%d %H:%M")}) + conn.execute("UPDATE candidates SET log=? WHERE code=?", (json.dumps(logs, ensure_ascii=False), code)) + + +# ── Stage 2: 多日K线确认 ── + +def fetch_daily_klines(code): + """拉取近10日日K线(Sina 240分钟线=日K)""" + raw = str(code).strip() + if raw.startswith(("6", "9")): + prefix = "sh" + elif raw.startswith(("0", "3")): + prefix = "sz" + else: + return None + + import subprocess as _sp, json as _json + url = f"http://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol={prefix}{raw}&scale=240&ma=5&datalen=10" + try: + r = _sp.run(["curl", "-s", "--noproxy", "*", url], capture_output=True, timeout=10) + data = _json.loads(r.stdout) + if not data: + return None + result = [] + for k in data: + result.append({ + "date": k.get("day", "")[:10], + "open": float(k["open"]), + "close": float(k["close"]), + "high": float(k["high"]), + "low": float(k["low"]), + "volume": int(k["volume"]), + "price": float(k["close"]), + "change_pct": 0, + }) + # 计算涨跌幅 + for i in range(1, len(result)): + prev = result[i-1]["close"] + if prev > 0: + result[i]["change_pct"] = (result[i]["close"] / prev - 1) * 100 + return result + except Exception as e: + return None + return None + + +def stage2_confirm(code, name, klines): + """第二关:多日K线确认 + 检查:多日量价配合、建仓特征 + """ + if not klines or len(klines) < 3: + return False, 0, "K线不足3日" + + recent = klines[-5:] # 最近5日 + score = 0 + checks = [] + + # 1. 成交量连续递增 + vols = [k["volume"] for k in recent] + vol_rising = sum(1 for i in range(len(vols)-1) if vols[i] < vols[i+1]) + if vol_rising >= 3: + score += 2 + checks.append(f"量增{vol_rising}/4日") + elif vol_rising >= 2: + score += 1 + checks.append(f"量微增{vol_rising}/4日") + + # 2. 涨放量、跌缩量 + up_vol = sum(k["volume"] for k in recent if k["change_pct"] >= 0) + down_vol = sum(k["volume"] for k in recent if k["change_pct"] < 0) + if down_vol > 0 and up_vol / down_vol > 1.5: + score += 2 + checks.append(f"涨量/跌量={up_vol/down_vol:.1f}") + elif down_vol > 0 and up_vol / down_vol > 1: + score += 1 + + # 3. 价格趋势 + closes = [k["close"] for k in recent] + up_days = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) + if up_days >= 3: + score += 2 + checks.append(f"涨{up_days}/4日") + elif up_days >= 2: + score += 1 + + # 4. 无异常放量(单日>3倍均量=可能出货) + avg_vol = sum(vols) / len(vols) if vols else 1 + max_ratio = max(v / avg_vol for v in vols) if avg_vol > 0 else 1 + if max_ratio < 2.5: + score += 1 + else: + checks.append(f"异常量{max_ratio:.0f}倍") + + passed = score >= 4 + detail = f"评分{score}/7 | {'; '.join(checks)}" + return passed, score, detail + + +# ── Stage 3: 技术位分析 ── + +def stage3_technical(code, name, klines): + """第三关:技术位(当日数据估算)""" + if not klines or len(klines) == 0: + return False, 0, "无数据" + + today = klines[-1] + price = today.get("price", 0) + high = today.get("high", 0) + low = today.get("low", 0) + + score = 0 + checks = [] + + if price <= 0: + return False, 0, "价格无效" + + # 日内位置(在高低点中下段还有空间) + if high > low: + pos = (price - low) / (high - low) + if pos < 0.7: + score += 1 + checks.append(f"日内位置{pos:.0%}") + + # 有明确支撑(今日低点作为参考支撑) + if low > 0 and price > low: + score += 1 + checks.append(f"支撑{low:.2f}") + + # 有上涨空间(今日高点作为参考阻力) + if high > price: + upside = (high / price - 1) * 100 + if upside > 2: + score += 1 + checks.append(f"空间{upside:.0f}%") + + passed = score >= 2 + return passed, score, "; ".join(checks) if checks else "基础通过" + + +# ── Stage 4: 资金性质分析 ── + +def stage4_capital_flow(code, name): + """第四关:资金性质(从腾讯实时行情提取外盘/内盘比)""" + raw = str(code).strip() + if raw.startswith(("6", "9")): + prefix = "sh" + elif raw.startswith(("0", "3")): + prefix = "sz" + else: + return False, 0, "非A股" + + import subprocess as _sp + url = f"http://qt.gtimg.cn/q={prefix}{raw}" + try: + r = _sp.run(["curl", "-s", url], capture_output=True, timeout=10) + text = r.stdout.decode("gbk", errors="ignore") + parts = text.split("~") + if len(parts) < 40: + return False, 0, "数据不足" + + # 腾讯字段:[7]=外盘(主动买,股),[8]=内盘(主动卖,股) + try: + outer = int(float(parts[7])) if parts[7] else 0 # 外盘 + inner = int(float(parts[8])) if parts[8] else 0 # 内盘 + except: + return False, 0, "解析失败" + + if outer <= 0 or inner <= 0: + return False, 0, "无盘口数据" + + score = 0 + ratio = outer / inner if inner > 0 else 1 + checks = [] + + if ratio > 1.3: + score += 2 + checks.append(f"外/内={ratio:.2f}") + elif ratio > 1.0: + score += 1 + checks.append(f"买稍强{ratio:.2f}") + else: + checks.append(f"卖稍强{ratio:.2f}") + + # 绝对量也说明资金活跃度 + total = outer + inner + if total > 50000000: # >5000万股 + score += 1 + checks.append(f"活跃{total/10000:.0f}万") + + return score >= 1, score, "; ".join(checks) + except: + return False, 0, "接口失败" + + +# ── Stage 5: 基本面 ── + +def stage5_fundamental(code, name, price): + """第五关:基本面 + 从已有数据判断,不调外部API + """ + conn = get_conn() + score = 0 + checks = [] + + # PE(从stocks表或live_prices) + r = conn.execute("SELECT 1 FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + is_holding = r is not None + if is_holding: + checks.append("已持仓") + else: + score += 1 # 新标的加分 + + # 检查是否已被其他候选覆盖 + r2 = conn.execute("SELECT code FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if r2: + checks.append("已有策略") + else: + score += 1 + + conn.close() + return score >= 1, score, "; ".join(checks) if checks else "新标的" + + +# ── 主流程 ── + +def main(): + stage_filter = None + single_code = None + for i, arg in enumerate(sys.argv[1:]): + if arg == "--stage" and i+1 < len(sys.argv): + stage_filter = int(sys.argv[i+2]) + if arg == "--code" and i+1 < len(sys.argv): + single_code = sys.argv[i+2] + + conn = get_conn() + + # 读待过滤的候选 + query = "SELECT code, name, reason FROM candidates WHERE 1=1" + params = [] + if single_code: + query += " AND code=?" + params.append(single_code) + else: + query += " AND (pass_final IS NULL OR pass_final=0)" + + rows = conn.execute(query, params).fetchall() + print(f"[FILTER] 待处理候选: {len(rows)}只", flush=True) + + stages = [(2, stage2_confirm, "多日K线"), (3, stage3_technical, "技术位"), + (4, stage4_capital_flow, "资金流"), (5, stage5_fundamental, "基本面")] + + for code, name, reason in rows: + current_score = 0 + print(f" {code} {name}", flush=True) + + # 获取K线(多关需要) + klines = None + + for stage_num, stage_fn, stage_name in stages: + if stage_filter and stage_num != stage_filter: + continue + + # 检查是否已通过此关 + col = f"pass_s{stage_num}" + existing = conn.execute(f"SELECT {col} FROM candidates WHERE code=?", (code,)).fetchone() + if existing and existing[0]: + continue + + if stage_num in (2, 3) and klines is None: + klines = fetch_daily_klines(code) + + if stage_num == 2: + passed, sscore, detail = stage_fn(code, name, klines) + conn.execute("UPDATE candidates SET score_2nd=?, pass_s2=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 2, passed, detail) + print(f" S2:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 3: + passed, sscore, detail = stage_fn(code, name, klines) + conn.execute("UPDATE candidates SET score_3rd=?, pass_s3=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 3, passed, detail) + print(f" S3:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 4: + passed, sscore, detail = stage_fn(code, name) + conn.execute("UPDATE candidates SET score_4th=?, pass_s4=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 4, passed, detail) + print(f" S4:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 5: + price = 0 # 从live_prices获取 + r = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if r: price = r[0] + passed, sscore, detail = stage_fn(code, name, price) + conn.execute("UPDATE candidates SET score_5th=?, pass_s5=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 5, passed, detail) + print(f" S5:{'✅' if passed else '❌'} {detail}", flush=True) + + # 计算综合评分 + s2 = conn.execute("SELECT score_2nd FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s3 = conn.execute("SELECT score_3rd FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s4 = conn.execute("SELECT score_4th FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s5 = conn.execute("SELECT score_5th FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + final = current_score + s2 + s3 + s4 + s5 + conn.execute("UPDATE candidates SET score_final=?, pass_final=1 WHERE code=?", + (final, code)) + + conn.commit() + conn.close() + print(f"[FILTER] 完成", flush=True) + +if __name__ == "__main__": + main() diff --git a/scripts/fix_gateway_port.py b/scripts/fix_gateway_port.py new file mode 100644 index 00000000..283cc1b7 --- /dev/null +++ b/scripts/fix_gateway_port.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""fix_gateway_port.py — 自愈系统调用的网关/XMPP Bot修复脚本 +v2: 新增 session 健康检查,检测卡死的 session 自动重启 +""" +import subprocess, sys, time, socket, json, urllib.request + +GATEWAY_PORT = 8643 +BOT_PORT = 5805 +BOT_SCRIPT = "/home/hmo/xmpp_zhiwei_bot.py" +API_KEY = "hermes123" +SESSION_ID = "xmpp-zhiwei" +GATEWAY_URL = f"http://127.0.0.1:{GATEWAY_PORT}/v1/chat/completions" + +def port_open(port, host="127.0.0.1"): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + try: + r = s.connect_ex((host, port)) + return r == 0 + finally: + s.close() + +def check_session_health(): + """检测 gateway LLM 是否可用——扫 agent.log 最近一次真实调用结果。 + 不再发真实 LLM ping(25s 超时对 20-100s 的冷启动延迟必误报,且每次白烧 22k token)。 + """ + try: + sys.path.insert(0, '/home/hmo/MoFin') + from xmpp_logger import _scan_agent_log + r = _scan_agent_log(time.time(), "zhiwei") + if r["status"] == "ok": + print(f"Session {SESSION_ID} 健康 ✓ (agent.log: latency={r.get('latency')}, {r.get('age_sec')}s前)") + return True + # error/unknown:只有近期有明确失败记录才判不健康 + if r["status"] == "error": + print(f"Session {SESSION_ID} 不健康: agent.log 最近调用失败 — {r.get('error','')[:100]}", file=sys.stderr) + return False + # unknown(无近期调用记录)= 空闲,不算不健康 + print(f"Session {SESSION_ID} 无近期调用记录(空闲正常)") + return True + except Exception as e: + print(f"Session {SESSION_ID} 健康检查异常: {e}(按健康处理)", file=sys.stderr) + return True + +def restart_gateway(): + """通过systemd重启gateway""" + print(f"Gateway 端口{GATEWAY_PORT} 异常 → 重启中...") + subprocess.run(["sudo", "systemctl", "restart", "hermes-gateway-zhiwei.service"], + timeout=30, capture_output=True) + time.sleep(5) + if port_open(GATEWAY_PORT): + print(f"Gateway 已恢复 ✓") + return True + else: + print(f"Gateway 重启后仍不可达", file=sys.stderr) + return False + +target = sys.argv[1] if len(sys.argv) > 1 else "all" + +if target in ("all", "bot", "xmpp_bot"): + if not port_open(BOT_PORT): + print(f"XMPP bot port {BOT_PORT} CLOSED → 启动") + subprocess.run(["pkill", "-f", "xmpp_zhiwei_bot.py"], timeout=5, capture_output=True) + time.sleep(1) + subprocess.Popen(["python3", BOT_SCRIPT], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(5) + if port_open(BOT_PORT): + print(f"XMPP bot 端口{BOT_PORT} 已打开 ✓") + else: + print(f"XMPP bot 修复后仍不可达", file=sys.stderr) + sys.exit(1) + else: + print(f"XMPP bot 端口{BOT_PORT} 正常 ✓") + +if target in ("all", "gateway", "session"): + if not port_open(GATEWAY_PORT): + restart_gateway() + elif target in ("all", "gateway") or target == "session": + # 端口通了 → 进一步检查session健康 + if not check_session_health(): + print(f"Session {SESSION_ID} 不健康 → 重启gateway") + restart_gateway() + else: + print(f"Gateway 端口{GATEWAY_PORT} 正常 ✓") + +if target in ("all", "session"): + # 仅session检查 + if port_open(GATEWAY_PORT): + if not check_session_health(): + print(f"Session {SESSION_ID} 不健康 → 重启gateway") + restart_gateway() + +sys.exit(0) diff --git a/scripts/morning_health_check.py b/scripts/morning_health_check.py new file mode 100644 index 00000000..dd89543d --- /dev/null +++ b/scripts/morning_health_check.py @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +"""morning_health_check.py — MoFin 系统常规体检 + +每日开盘前(8:00)运行,全面扫描MoFin所有组件健康状况。 +当前8类48项(清单自动扩展)。 +输出格式化的体检报告,有问题才出声,没问题静默。 + +核心设计: + 从 health_checklist.json 读检查清单 + 逐项检查,记录状态 + 报告异常项(只推异常,不推正常) + 自动发现新增cron/脚本(通过 self_discovery 函数) + 维护检查历史 (health_check_log 表) + 自动修复可修问题,不可修写TODO + + 新增组件自动发现机制: + - 对比当前cron list与checklist中记录的cron id + - 发现新cron → 自动追加到checklist + - 脚本修改 → 标记"需复核" + +用法: + python3 scripts/morning_health_check.py [--report] [--update-checklist] + --report: 强制输出完整报告(默认只输出异常) + --update-checklist: 运行自动发现并更新checklist + +no_agent模式:只输出异常项,无异常完全静默 +""" + +import json, os, sqlite3, subprocess, sys, time, urllib.request +from pathlib import Path +from datetime import datetime, timedelta + +# ── 路径 ── +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +SCRIPTS_DIR = BASE / "scripts" +PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts") +CHECKLIST_PATH = DATA / "health_checklist.json" +DB_PATH = DATA / "mofin.db" +HERMES_CRON_DIR = Path("/home/hmo/.hermes/profiles/position-analyst/cron") + + +def derive_fix_action(detail, msg): + """根据issue信息推导可执行的修复命令""" + # system-audit error → 验证拷贝 + if "system_audit" in msg or "系统审计" in msg: + return f"ls -la /home/hmo/.hermes/profiles/position-analyst/scripts/system_audit.py 2>&1" + # cron errors(last_status=error)→ 验证文件存在,等下次cron运行自动恢复 + if "cron" in msg.lower() and "error" in msg.lower() and "系统审计" in msg: + return f"ls -la /home/hmo/.hermes/profiles/position-analyst/scripts/system_audit.py 2>&1" + # 港股汇率 → 刷新 + if "港股汇率" in msg: + return f"cd {BASE} && python3 hk_rate.py 2>&1" + # 价格监控无事件 → 检查进程 + if "价格监控" in msg and "0 rows" in msg: + return "ps aux | grep price_monitor | grep -v grep | head -3" + # delivery目标缺失 → 改为local + if "deliver" in msg.lower() or "delivery" in msg.lower(): + return f"cd {BASE} && echo '需手动设置: cronjob action=update deliver=local'" + # 小果→知微桥不通(小果已归档,不再自动修复) + if "信号桥" in msg: + return None # 小果已归档,信号桥不再使用 + return None + + +def auto_fix_issue(issue): + """对明确可自动修复的问题执行修复,返回 (fixed, fix_msg)""" + item_id = issue.get("detail", "") + msg = issue.get("msg", "") + + # 港股汇率缓存缺失 → 生成 + if "港股汇率缓存" in msg and "missing" in msg: + try: + # hk_rate.py 写入 ~/.cache/hk_exchange_rate.json,profile环境下解析到 profile/home/.cache/ + r = subprocess.run( + ["python3", str(BASE / "hk_rate.py")], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0: + return True, f"已自动刷新港股汇率缓存: {r.stdout.strip()}" + else: + return False, f"汇率刷新失败: {r.stderr[:100]}" + except Exception as e: + return False, f"汇率刷新异常: {e}" + + # 价格监控今天无事件(交易日盘中)→ 检查进程 + if "价格监控" in msg and "0 rows" in msg: + now = ctx["started_at"] + if now.weekday() < 5 and 9 <= now.hour <= 15: + # 交易时段,应该有事 + ok, detail = check_process("price_monitor") + if not ok: + return True, "已检测:price_monitor进程不存在(需人工介入)" + return True, "已确认:price_monitor进程运行中,但今日无事件(可能无价格触发)" + # 非交易时段→正常 + return True, "非交易时段无价格事件属正常" + + # 其他问题→不可自动修复 + return False, "需人工处理" + + +def write_todos_for_issues(): + """将体检发现的异常写入 TODO 系统(去重、升级),先尝试自动修复""" + try: + if not ctx["report"]: + return + + # 只有 error/critical/warn 才处理 + issues = [e for e in ctx["report"] if e["level"] in ("critical", "error", "warn")] + if not issues: + return + + # 先尝试自动修复 + fixed_issues = [] + remaining = [] + for issue in issues: + fixed, fix_msg = auto_fix_issue(issue) + if fixed: + fixed_issues.append((issue, fix_msg)) + log("ok", issue["category"], f"已自动修复: {fix_msg}", issue.get("detail","")) + else: + remaining.append(issue) + + # 输出修复摘要 + if fixed_issues: + print() + print("🛠️ 自动修复:") + for issue, fix_msg in fixed_issues: + print(f" ✅ {issue['category']}: {fix_msg}") + + # 剩余的无法自动修复的→写TODO到数据库 + if not remaining: + return + + try: + conn = sqlite3.connect(str(DB_PATH)) + todo_priority = {"critical": "high", "error": "medium", "warn": "low"} + new_count = 0 + + for issue in remaining: + title = f"[体检发现] {issue['msg']}" + level = issue["level"] + pri = todo_priority.get(level, "medium") + + # 去重:检查是否已存在(含completed的也要查,避免重复加) + r_exist = conn.execute( + "SELECT id, status FROM todos WHERE title=?", + (title,) + ).fetchone() + + if r_exist: + if r_exist[1] == "blocked": + # 已阻塞的重新打开 + conn.execute( + "UPDATE todos SET status='pending', priority=?, note='已重新打开', updated_at=CURRENT_TIMESTAMP WHERE id=?", + (pri, r_exist[0]) + ) + else: + # 生成fix_action(必须非空) + fix_action = derive_fix_action(issue.get("detail", ""), issue.get("msg", "")) + if not fix_action: + # 没有fix_action就不创建TODO,直接输出到报告里 + print(f" ⚠️ 无法自动修复: [{pri}] {title[:60]}") + print(f" 原因: 未知修复方案,需人工分析") + continue + conn.execute( + "INSERT INTO todos (title, description, priority, source, status, fix_action) " + "VALUES (?, ?, ?, 'health_check', 'pending', ?)", + (title, + f"体检发现于 {ctx['started_at'].strftime('%Y-%m-%d %H:%M')}\n分类: {issue['category']}\n详情: {issue.get('detail', '')}\n无法当场修复原因: 需验证/需等待", + pri, fix_action) + ) + new_count += 1 + + conn.commit() + + if new_count > 0: + print() + print(f"📋 已加入TODO({new_count}条):") + for r2 in conn.execute( + "SELECT title, priority FROM todos WHERE status='pending' AND source='health_check' " + "ORDER BY created_at DESC LIMIT ?", (new_count,) + ).fetchall(): + print(f" [{r2[1]}] {r2[0][:70]}") + conn.close() + except Exception as e: + print(f" TODO写入异常: {e}") + except Exception as e: + pass # TODO 写入失败不阻碍体检主流程 + +# ── 上下文 ── +ctx = { + "report": [], + "issues": [], + "ok_count": 0, + "warn_count": 0, + "error_count": 0, + "critical_count": 0, + "started_at": datetime.now(), +} + +def log(level, category, msg, detail=None): + """记录检查结果""" + ctx["report"].append({ + "level": level, "category": category, "msg": msg, "detail": detail, + "timestamp": datetime.now().isoformat() + }) + if level == "critical": + ctx["critical_count"] += 1 + elif level == "error": + ctx["error_count"] += 1 + elif level == "warn": + ctx["warn_count"] += 1 + else: + ctx["ok_count"] += 1 + +def emit(msg, level="ok"): + """输出一行""" + prefix = {"critical": "🔴", "error": "❌", "warn": "⚠️", "ok": "✅", "info": "📎"}.get(level, "•") + return f"{prefix} {msg}" + +# ── 检查器集合 ── + +def check_systemctl(service_name): + """检查systemd服务状态""" + try: + r = subprocess.run(["systemctl", "is-active", service_name], + capture_output=True, text=True, timeout=5) + status = r.stdout.strip() + return status == "active", f"{status}" + except Exception as e: + return False, f"error:{e}" + +def check_port(port): + """检查端口是否在监听""" + try: + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + return f":{port}" in r.stdout, "listening" if f":{port}" in r.stdout else "not_found" + except Exception as e: + return False, f"error:{e}" + +def check_process(pattern): + """检查进程是否存在""" + try: + r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) + return r.returncode == 0, "running" if r.returncode == 0 else "not_found" + except: + return False, "check_error" + +def check_http(url, timeout=15): + """检查HTTP端点是否可达 (清理代理环境变量)""" + try: + # 清理所有代理环境变量 + old_env = {} + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + old_env[k] = os.environ.pop(k) + req = urllib.request.Request(url, method="GET") + resp = urllib.request.urlopen(req, timeout=timeout) + # 恢复 + for k, v in old_env.items(): + os.environ[k] = v + return True, str(resp.status) + except Exception as e: + return False, str(e)[:60] + +def check_disk(mount): + """检查磁盘空间""" + try: + r = subprocess.run(["df", "-h", mount], capture_output=True, text=True, timeout=5) + lines = r.stdout.strip().split("\n") + if len(lines) >= 2: + parts = lines[1].split() + if len(parts) >= 5: + pct = parts[4].replace("%", "") + return int(pct) < 90, f"{pct}% used" + return False, "parse_error" + except: + return False, "check_error" + +def check_file_exists(path): + """检查文件存在""" + p = Path(path) + exists = p.exists() + return exists, f"{p.stat().st_size}B" if exists else "missing" + +def check_file_freshness(path, max_hours): + """检查文件新鲜度""" + p = Path(path) + if not p.exists(): + return False, "missing" + mtime = datetime.fromtimestamp(p.stat().st_mtime) + hours_ago = (ctx["started_at"] - mtime).total_seconds() / 3600 + return hours_ago < max_hours, f"{hours_ago:.0f}h ago (threshold {max_hours}h)" + +def check_db_table_count(table, field, value, op="today", threshold=0): + """检查数据库中的记录数""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cur = conn.cursor() + if op == "today": + today = ctx["started_at"].strftime("%Y-%m-%d") + # 先检查表有哪些列 + cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()] + date_col = None + for candidate in ["created_at", "date", "timestamp", "last_scanned_at", "signal_date"]: + if candidate in cols: + date_col = candidate + break + if not date_col: + conn.close() + return True, f"no_date_col_in_{table}" + sql = f"SELECT COUNT(*) FROM {table} WHERE date({date_col}) = ?" + cur.execute(sql, (today,)) + elif op == "unprocessed": + cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()] + if "processed" in cols: + sql = f"SELECT COUNT(*) FROM {table} WHERE (processed = 0 OR processed IS NULL)" + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + elif op == "count": + if field: + sql = f"SELECT COUNT(*) FROM {table} WHERE {field} = ?" + cur.execute(sql, (value,)) + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + count = cur.fetchone()[0] + conn.close() + if op == "unprocessed": + return count < threshold, f"{count} unprocessed" + return count >= threshold, f"{count} rows" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_cron(job_id): + """检查cron任务状态(通过jobs.json)""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if cron_jobs_path.exists(): + data = json.loads(cron_jobs_path.read_text()) + for job in data.get("jobs", []): + if job.get("id") == job_id or (not job.get("id") and job.get("name") and job_id in str(job)): + enabled = job.get("enabled", True) + if not enabled: + return False, "disabled" + last_status = job.get("last_status") + if last_status and last_status != "ok": + return False, f"status={last_status}" + last_run = job.get("last_run_at", "") + if last_run: + try: + last_dt = datetime.fromisoformat(last_run) + hours_ago = (ctx["started_at"] - last_dt).total_seconds() / 3600 + if hours_ago > 48: + return True, f"ok(stale:{hours_ago:.0f}h)" + except: + pass + return True, "ok" + # 没找到该job_id - 可能是不需要检查的cron + return True, "not_in_jobs_json" + return False, "no_jobs_json" + except Exception as e: + return False, f"check_error:{str(e)[:60]}" + +def check_cron_errors_last24h(): + """检查最近24h内cron是否有error状态""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + check_time = (ctx["started_at"] - timedelta(hours=24)).isoformat() + errors = [] + for job in data.get("jobs", []): + last_status = job.get("last_status") + last_run = job.get("last_run_at", "") + if last_status and last_status != "ok" and last_run: + try: + if last_run >= check_time[:19]: + errors.append(f"{job.get('name','?')}({last_status})") + except: + pass + if errors: + return False, f"{len(errors)} errors: {'; '.join(errors[:5])}" + return True, "0 errors" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_cron_paused(): + """检查不应暂停的cron是否被误暂停""" + should_run = [ + ("3a9fb3300a6a", "价格监控"), + ("e13323928f3a", "自选提醒"), + ("b809fcabfa5b", "分支评估"), + ] + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + job_map = {job.get("id"): job for job in data.get("jobs", [])} + paused = [] + for jid, name in should_run: + job = job_map.get(jid) + if job and not job.get("enabled", True): + paused.append(name) + if paused: + return False, f"paused: {', '.join(paused)}" + return True, "all_expected_running" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_delivery_targets(): + """检查deliver=origin的cron是否有目标""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + issues = [] + for job in data.get("jobs", []): + last_delivery_err = job.get("last_delivery_error", "") + if last_delivery_err and "delivery" in last_delivery_err.lower(): + issues.append(f"{job.get('name','?')}") + if issues: + return False, f"{len(issues)} issues: {', '.join(issues[:3])}" + return True, "all_ok" + except Exception as e: + return True, f"skip({str(e)[:60]})" + + +def check_cron_audit(): + """审计全部cron:最近24h内是否运行过""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + check_time = (ctx["started_at"] - timedelta(hours=24)).isoformat()[:19] + stale = [] + for job in data.get("jobs", []): + name = job.get("name", "?") + enabled = job.get("enabled", True) + script = job.get("script", "") + last_run = job.get("last_run_at", "") + last_status = job.get("last_status") + if not enabled or not script: + continue + if not last_run: + stale.append(f"{name}(从未运行)") + continue + if last_run[:19] < check_time: + if last_status and last_status == "ok": + stale.append(f"{name}(>24h未运行)") + else: + stale.append(f"{name}(>24h+状态异常)") + if stale: + return False, f"{len(stale)}个cron异常: {'; '.join(stale[:5])}" + total = sum(1 for j in data.get("jobs",[]) if j.get("enabled") and j.get("script")) + return True, f"全部{total}个cron正常" + except Exception as e: + return True, f"skip({str(e)[:60]})" + + +def check_meta_health_check_yesterday(): + """元检:昨天体检是否正常完成""" + try: + conn = sqlite3.connect(str(DB_PATH)) + yesterday = (ctx["started_at"] - timedelta(days=1)).strftime("%Y-%m-%d") + row = conn.execute( + "SELECT ok_count, error_count, critical_count FROM health_check_log " + "WHERE date(created_at) = ? ORDER BY created_at DESC LIMIT 1", + (yesterday,) + ).fetchone() + conn.close() + if row: + if row[1] == 0 and row[2] == 0: + return True, f"昨日体检通过({row[0]}项正常)" + return True, f"昨日体检有{row[1]}错误+{row[2]}严重(已记录)" + return True, "无昨日记录(首次运行)" + except: + return True, "skip" + + +def check_meta_checklist_completeness(): + """元检:检查清单是否覆盖了所有已知组件""" + try: + added = ctx.get("auto_discovered_items", []) + if added: + return True, f"自动发现并追加了{len(added)}个新组件到清单" + return True, "清单覆盖完整" + except: + return True, "skip" + + +# ── 自动发现 ── +def self_discovery(): + """自动发现新增组件并更新checklist""" + discovered = [] + + # 1. 发现新增cron任务 + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if cron_jobs_path.exists(): + data = json.loads(cron_jobs_path.read_text()) + all_crons = [(j.get("id"), j.get("name"), j.get("schedule"), j.get("script")) + for j in data.get("jobs", [])] + + # 读当前checklist中已有的cron ID + checklist = json.loads(CHECKLIST_PATH.read_text()) + known_cron_ids = set() + for cat in checklist["categories"]: + for item in cat["items"]: + if item["check"].startswith("cron:"): + known_cron_ids.add(item["check"].split(":")[1]) + + for jid, name, schedule, script in all_crons: + if jid and jid not in known_cron_ids and script: + # 新cron任务,追加到pipeline类 + discovered.append(f"新cron: {name}({jid})") + for cat in checklist["categories"]: + if cat["id"] == "pipeline": + cat["items"].append({ + "id": f"cron-auto-{jid[:8]}", + "description": f"{name} cron 已调度", + "check": f"cron:{jid}", + "expected": "enabled+ok", + "severity": "medium", + "auto_discovered": True + }) + break + + if discovered: + CHECKLIST_PATH.write_text(json.dumps(checklist, ensure_ascii=False, indent=2)) + except Exception as e: + discovered.append(f"cron_discovery_error: {e}") + + return discovered + +# ── 主流程 ── + +def run_check(item): + """运行单个检查项""" + check_spec = item["check"] + expected = item["expected"] + + if check_spec.startswith("systemctl:"): + service = check_spec.split(":", 1)[1] + ok, detail = check_systemctl(service) + elif check_spec.startswith("port:"): + port = check_spec.split(":", 1)[1] + ok, detail = check_port(port) + elif check_spec.startswith("proc:"): + pattern = check_spec.split(":", 1)[1] + ok, detail = check_process(pattern) + elif check_spec.startswith("http:"): + url = check_spec.split(":", 1)[1] + ok, detail = check_http(url) + elif check_spec.startswith("disk:"): + mount = check_spec.split(":", 1)[1] + ok, detail = check_disk(mount) + elif check_spec.startswith("fileexists:"): + path = check_spec.split(":", 1)[1] + ok, detail = check_file_exists(path) + elif check_spec.startswith("filefresh:"): + # filefresh:path:max_hours + parts = check_spec.split(":", 2) + path = parts[1] + max_hours = float(parts[2].replace("h", "")) + ok, detail = check_file_freshness(path, max_hours) + elif check_spec.startswith("db:"): + # db:table:field:value:op:threshold + parts = check_spec.split(":", 5) + table = parts[1] + field = parts[2] if len(parts) > 2 else None + value = parts[3] if len(parts) > 3 else None + op = parts[4] if len(parts) > 4 else "today" + threshold = int(parts[5]) if len(parts) > 5 else 0 + ok, detail = check_db_table_count(table, field, value, op, threshold) + elif check_spec.startswith("cron:"): + job_id = check_spec.split(":", 1)[1] + ok, detail = check_cron(job_id) + elif check_spec == "cron_errors:last24h": + ok, detail = check_cron_errors_last24h() + elif check_spec == "cron_paused:check": + ok, detail = check_cron_paused() + elif check_spec == "delivery:origin_targets": + ok, detail = check_delivery_targets() + elif check_spec == "cron_audit:all": + ok, detail = check_cron_audit() + elif check_spec == "meta:health_check_yesterday": + ok, detail = check_meta_health_check_yesterday() + elif check_spec == "meta:checklist_completeness": + ok, detail = check_meta_checklist_completeness() + elif check_spec == "pipeline:xiaoguo_signal_flow": + # 小果已归档,此管道不再检查 + ok = True + detail = "小果已归档,跳过信号流检查" + elif check_spec == "pipeline:registry_audit": + ok = True + gaps = [] + unregistered = [] + try: + import json as j2 + reg = j2.loads(open(str(DATA / "pipeline_registry.json")).read()) + for p in reg.get("pipelines", []): + if not p.get("verified"): + gaps.append(p["name"]) + + # 自动发现:从cron jobs.json读所有脚本,交叉比对注册表 + known_sources = set() + for p in reg.get("pipelines", []): + src = p.get("source", "") + # 从source描述中提取脚本名 + import re as rr + for m in rr.findall(r'[\w_-]+\.py', src): + known_sources.add(m.replace('.py', '')) + + # 从jobs.json动态读取所有启用脚本 + unregistered = [] + try: + jobs_data = j2.loads(open(str(HERMES_CRON_DIR / "jobs.json")).read()) + active_scripts = set() + for job in jobs_data.get("jobs", []): + script = job.get("script", "") or "" + if script and job.get("enabled", True): + name = script.replace('.py', '') + active_scripts.add(name) + for s in sorted(active_scripts): + if s not in known_sources: + unregistered.append(s) + except Exception: + pass + + if unregistered: + ok = False + detail = f"{len(gaps)}条待验证 + {len(unregistered)}个新组件未注册" + # 自动修复:读脚本docstring,推算数据流 + try: + for s in unregistered: + script_path = HERMES_CRON_DIR.parent / "scripts" / f"{s}.py" + desc = "未知" + source_info = f"{s}.py" + consumer_info = "未知" + if script_path.exists(): + content = script_path.read_text() + # 提取docstring + import re as rr2 + doc_match = rr2.search(r'"""(.*?)"""', content, rr2.DOTALL) + if doc_match: + doc_text = doc_match.group(1).strip() + desc = doc_text.split('\\n')[0][:80] + # 尝试从docstring中提取管道信息 + pipe_match = rr2.search(r'管道[::].*?(?=\\n|$)', doc_text) + if pipe_match: + consumer_info = pipe_match.group(0).replace('管道','').strip(':: ') + # 检测写入模式 + if 'signal_news' in content: + consumer_info = 'signal_news表' + if 'macro_risk_state' in content: + consumer_info = 'macro_risk_state.json' + if 'watchlist' in content.lower(): + consumer_info = 'watchlist_stocks表 / holding_strategies表' + if 'INSERT INTO' in content: + for tbl in ['todos', 'price_events', 'macro_context_log', 'accuracy_stats']: + if tbl in content: + consumer_info = f'{tbl}表' + if '.write_text' in content or 'json.dump' in content: + for path in ['macro_risk_state', 'macro_context', 'market', 'portfolio', 'decisions']: + if path in content: + consumer_info = f'{path}.json' + reg["pipelines"].append({ + "id": f"auto-{s}", + "name": desc[:60], + "source": source_info, + "consumer": consumer_info, + "end_user": "待确认", + "verified": False, + "gap": f"自动发现({desc[:60]})", + "fix": "手动编辑pipeline_registry.json完善此项" + }) + open(str(DATA / "pipeline_registry.json"), 'w').write( + j2.dumps(reg, ensure_ascii=False, indent=2)) + detail += f" → 已自动注册{len(unregistered)}个(含推断)" + except Exception as e: + detail += f" (自动注册异常:{str(e)[:30]})" + elif gaps: + ok = False + detail = f"{len(gaps)}条管道未验证: {', '.join(gaps[:5])}" + else: + detail = f"全部{len(reg['pipelines'])}条管道正常" + except Exception as e: + ok = True + detail = f"注册表不可读({str(e)[:60]})" + else: + ok = False + detail = f"unknown_check:{check_spec}" + + level = "ok" if ok else item["severity"] + # 将critical/error/high都映射到error级别 + if not ok: + if item["severity"] == "critical": + level = "critical" + elif item["severity"] in ("high", "error"): + level = "error" + else: + level = "warn" + + return ok, level, detail + +def main(): + show_full = "--report" in sys.argv + update = "--update-checklist" in sys.argv + + start_time = time.time() + + # 加载checklist + if not CHECKLIST_PATH.exists(): + print("[SILENT] health_checklist.json 不存在") + return + + checklist = json.loads(CHECKLIST_PATH.read_text()) + + # 自动发现(每小时仅运行一次) + if update: + discovered = self_discovery() + else: + # 定期自动发现(检查上次扫描时间) + meta = checklist.get("meta", {}) + last_scan = meta.get("last_full_scan") + if not last_scan or (ctx["started_at"] - datetime.fromisoformat(last_scan)).total_seconds() > 3600: + discovered = self_discovery() + checklist["meta"]["last_full_scan"] = ctx["started_at"].isoformat() + CHECKLIST_PATH.write_text(json.dumps(checklist, ensure_ascii=False, indent=2)) + else: + discovered = [] + + # 按分类逐项检查 + dayname = ["一","二","三","四","五","六","日"][ctx["started_at"].weekday()] + lines = [f"MoFin 系统体检 | {ctx['started_at'].strftime('%Y-%m-%d')} 周{dayname} | {ctx['started_at'].strftime('%H:%M')}"] + lines.append("─" * 50) + + for cat in checklist["categories"]: + cat_issues = 0 + cat_lines = [] + for item in cat["items"]: + ok, level, detail = run_check(item) + msg = f"{item['description']}: {detail}" + log(level, cat["name"], msg, item["id"]) + cat_lines.append(emit(msg, level)) + if not ok: + cat_issues += 1 + + # 只在该分类有问题或--report时才输出 + if cat_issues > 0 or show_full: + lines.append(f"\n【{cat['name']}】") + lines.extend(cat_lines) + + # 自动发现结果 + if discovered: + lines.append(f"\n📎 自动发现:") + for d in discovered: + lines.append(f" {d}") + + # 汇总 + total = ctx["ok_count"] + ctx["warn_count"] + ctx["error_count"] + ctx["critical_count"] + if total == 0: + total = 1 # 避免除以0 + + lines.append(f"\n{'─' * 50}") + + critical = ctx["critical_count"] + errors = ctx["error_count"] + warns = ctx["warn_count"] + ok_count = ctx["ok_count"] + + # 构建严重级别输出 + severity_parts = [] + if critical > 0: + severity_parts.append(f"🔴{critical}严重") + if errors > 0: + severity_parts.append(f"❌{errors}错误") + if warns > 0: + severity_parts.append(f"⚠️{warns}警告") + if ok_count > 0: + severity_parts.append(f"✅{ok_count}正常") + + lines.append(f"总计: {' | '.join(severity_parts)} ({(time.time()-start_time):.0f}s)") + + report = "\n".join(lines) + + # 保存历史到DB + try: + conn_hist = sqlite3.connect(str(DB_PATH)) + details = json.dumps([e for e in ctx["report"] if e["level"] in ("critical", "error")]) + conn_hist.execute( + "INSERT INTO health_check_log (ok_count, warn_count, error_count, critical_count, duration_s, details) " + "VALUES (?, ?, ?, ?, ?, ?)", + (ok_count, warns, errors, critical, round(time.time()-start_time, 1), details)) + conn_hist.commit() + conn_hist.close() + except: + pass + + # 输出 + # no_agent模式:有问题才出声;--report则强制输出 + has_issues = critical > 0 or errors > 0 or warns > 0 + + if has_issues or show_full: + print(report) + else: + print(f"[SILENT] MoFin 体检通过 | {ok_count}/{total} 检查正常 ({(time.time()-start_time):.0f}s)") + + # 如果有严重问题,额外输出可读摘要 + if critical > 0 or errors > 0: + print() + print("🔴 需立即处理的问题:") + for entry in ctx["report"]: + if entry["level"] in ("critical", "error"): + print(f" [{entry['level'].upper()}] {entry['category']}: {entry['msg']}") + + # 检查是否有执行器升级来的TODO(通知失败挂起的) + try: + conn2 = sqlite3.connect(str(DB_PATH)) + needs_llm = conn2.execute( + "SELECT id, title, priority, created_at, note FROM todos " + "WHERE status='needs_llm' " + "ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at ASC LIMIT 10" + ).fetchall() + if needs_llm: + print() + print("🔶 需知微介入(执行器无法自动修复):") + for n in needs_llm: + note = (n[4] or "")[:60] + print(f" [{n[2]}] #{n[0]} {n[1][:60]} → {note}") + conn2.close() + except: + pass + + # 将异常写入 TODO 系统 + write_todos_for_issues() + + +if __name__ == "__main__": + # --verify-new-cron 模式:创建cron后立即验证完整性 + if "--verify-new-cron" in sys.argv: + idx = sys.argv.index("--verify-new-cron") + if idx + 1 < len(sys.argv): + script_name = sys.argv[idx + 1] + import json as j2 + from pathlib import Path as P2 + DATA = P2("/home/hmo/MoFin/data") + # 检查cron-catalog.md + catalog = DATA.parent / "docs" / "cron-catalog.md" + if catalog.exists(): + content = catalog.read_text() + if script_name in content: + print(f" ✅ cron-catalog.md: 已登记") + else: + print(f" ⚠️ cron-catalog.md: 未登记(可从docstring自动生成)") + # 检查pipeline_registry.json + reg_path = DATA / "pipeline_registry.json" + if reg_path.exists(): + reg = j2.loads(reg_path.read_text()) + registered = any(script_name in p.get("source","") for p in reg["pipelines"]) + if registered: + print(f" ✅ pipeline_registry.json: 已注册") + else: + print(f" ⚠️ pipeline_registry.json: 未注册(自动添加占位)") + # 读docstring自动注册 + script_path = P2("/home/hmo/.hermes/profiles/position-analyst/scripts") / f"{script_name}.py" + desc = script_name + if script_path.exists(): + import re + m = re.search(r'"""(.*?)"""', script_path.read_text(), re.DOTALL) + if m: + desc = m.group(1).strip().split('\n')[0][:80] + reg["pipelines"].append({ + "id": f"auto-{script_name}", "name": desc[:60], + "source": f"{script_name}.py", "consumer": "待确认", + "end_user": "待确认", "verified": False, + "gap": f"新建后自动注册({desc[:60]})", + "fix": "手动完善pipeline_registry.json" + }) + reg_path.write_text(j2.dumps(reg, ensure_ascii=False, indent=2)) + print(f" 已自动添加占位记录(desc={desc[:50]})") + sys.exit(0) + main() diff --git a/scripts/per_stock_reassess.py b/scripts/per_stock_reassess.py new file mode 100644 index 00000000..b84fd8fc --- /dev/null +++ b/scripts/per_stock_reassess.py @@ -0,0 +1,822 @@ +#!/usr/bin/env python3 +""" +per_stock_reassess.py — 按个股触发重评 + +对每只传进来的 code 执行 reassess_with_context(),然后写入 +DB holding_strategies 表(纯DB模式,已移除JSON依赖)。 +""" +import sys, json, os, re +from datetime import datetime + +COOLDOWN_HOURS_TRADING = 1 # 交易时段冷却(1小时) +COOLDOWN_HOURS_NONTRADING = 24 # 非交易时段冷却 + +def _in_cooldown(code): + """检查个股是否在重评冷却期内""" + try: + import sqlite3 + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active' ORDER BY id DESC LIMIT 1", (code,)).fetchone() + conn.close() + if not r or not r[0]: + return False # 从未重评,立即执行 + last = datetime.fromisoformat(r[0]) + now = datetime.now() + # 交易时段 vs 非交易时段 + if 9 <= now.hour < 15: + hours = COOLDOWN_HOURS_TRADING + else: + hours = COOLDOWN_HOURS_NONTRADING + diff = (now - last).total_seconds() / 3600 + return diff < hours + except: + return False + +sys.path.insert(0, "/home/hmo/web-dashboard") +sys.path.insert(0, "/home/hmo/MoFin") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # profile-scripts 硬链目录 +from strategy_lifecycle import reassess_with_context as reassess_strategy +from mo_data import read_decisions, read_portfolio +from llm_client import call_llm, REASSESS_MODEL +from mofin_db import snapshot_strategy_history + + +def _build_full_analysis(code, entry, result): + """从重评结果构建完整九维分析文本""" + if not result: + return "" + lines = [] + name = entry.get("name", code) + price = result.get("price") or entry.get("price", 0) + + tech = result.get("tech_snapshot") or entry.get("tech_snapshot", "") + sector = result.get("sector_context") or entry.get("sector_context", "") + signal = result.get("timing_signal") or entry.get("timing_signal", "") + category = result.get("stock_category") or entry.get("stock_category", "") + + el = result.get("entry_low") or entry.get("entry_low", 0) + eh = result.get("entry_high") or entry.get("entry_high", 0) + sl = result.get("stop_loss") or entry.get("stop_loss", 0) + tp = result.get("take_profit") or entry.get("take_profit", 0) + rr = result.get("rr_ratio") or entry.get("rr_ratio", 0) + act = result.get("action", "") + + # ── 从DB拉取大盘、基本面、资金流 ── + macro_desc = "" + pe_val = pb_val = "" + try: + import sqlite3 as _sq, json as _j + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + # 大盘(从structure列读取) + _m = _db.execute("SELECT structure, sector_mood FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if _m and _m[0]: + _st = _j.loads(_m[0]) + _ix = _st.get("indices", {}) + _desc = _st.get("description", "") + if _ix: + _parts = [] + for _name in ["上证指数", "深证成指", "创业板指", "科创50", "恒生指数"]: + if _name in _ix: + _d = _ix[_name] + if isinstance(_d, dict): + _p = _d.get("price", 0) + _c = _d.get("change_pct", 0) + _parts.append(f"{_name}({_p:.0f},{_c:+.1f}%)") + elif isinstance(_d, (int, float)): + _parts.append(f"{_name}({_d})") + macro_desc = " ".join(_parts) + elif _desc: + macro_desc = _desc + _mood = str(_m[1] or "") + if _mood and not macro_desc: + macro_desc = f"情绪={_mood}" + elif _mood: + macro_desc += f" 情绪={_mood}" + if not macro_desc: + # fallback: 直接用腾讯API拉大盘 + try: + _r2 = __import__('subprocess').run(["curl", "-s", "http://qt.gtimg.cn/q=sh000001,sz399001,sz399006,sh000688"], + capture_output=True, timeout=10) + _txt = _r2.stdout.decode("gbk", errors="ignore") + _parts = [] + for _line in _txt.strip().split("\n"): + if "~" not in _line: continue + _p = _line.split("~") + if len(_p) < 4: continue + _name2 = _p[1] + _price2 = _p[3] + _chg2 = _p[32] if len(_p) > 32 else "0" + _parts.append(f"{_name2}({_price2},{_chg2}%)") + if _parts: + macro_desc = "腾讯实时 " + " ".join(_parts[:3]) + except: + pass + # 基本面+实时价:直接从腾讯API拉(盘后也有收盘价) + try: + _pfx = "sh" if str(code).startswith(("6", "9")) else "sz" + _r3 = __import__('subprocess').run(["curl", "-s", f"http://qt.gtimg.cn/q={_pfx}{code}"], + capture_output=True, timeout=10) + _txt3 = _r3.stdout.decode("gbk", errors="ignore") + _p3 = _txt3.split("~") + if len(_p3) > 45: + _pe = _p3[39] if _p3[39] else "" + _pb = _p3[40] if len(_p3) > 40 and _p3[40] else "" + _mcap = _p3[44] if len(_p3) > 44 and _p3[44] else "" + _price_now = float(_p3[3]) if _p3[3] else 0 + _chg_now = float(_p3[32]) if len(_p3) > 32 and _p3[32] else 0 + if _price_now > 0: + price = _price_now # 覆盖策略中的price=0 + if _pe: pe_val = f"PE={_pe}" + if _pb: pb_val = f"PB={_pb}" + if _mcap: + mcap_val = f"市值{float(_mcap)/10000:.1f}亿" if float(_mcap) > 10000 else f"市值{_mcap}万" + pe_val += f" {mcap_val}" if pe_val else mcap_val + except: + pass + _db.close() + except Exception as _e: + pass + + # ── 从tech_snapshot提取MA和支撑阻力 ── + import re + ma5 = ma10 = ma20 = ma60 = "?" + ma_match = re.search(r'MA5=([\d.]+).*?MA10=([\d.]+).*?MA20=([\d.]+).*?MA60=([\d.]+)', tech) + if ma_match: + ma5, ma10, ma20, ma60 = ma_match.groups() + + lines.append(f"【{name}({code} 九维全析)】") + lines.append("") + if macro_desc: + lines.append(f"① 大盘环境(当日实时):{macro_desc}") + else: + lines.append(f"① 大盘环境(当日实时):数据待刷新") + if pe_val or pb_val: + lines.append(f"② 个股基本面(最新财报):{pe_val} {pb_val}") + else: + lines.append(f"② 个股基本面(最新财报):数据待补充") + lines.append(f"③ 技术面(MA5/10/20/60日 支撑阻力近20日):MA5={ma5} MA10={ma10} MA20={ma20} MA60={ma60}") + if el and eh and price > 0: + pos = "在买入区内" if el <= price <= eh else (f"低于买入区{(1-price/el)*100:.0f}%" if price < el else f"高于买入区{(price/eh-1)*100:.0f}%") + lines.append(f"④ 价格位置:{price} {pos} 区间{el}~{eh}") + else: + lines.append(f"④ 价格位置:数据待刷新") + if sl and tp and rr: + lines.append(f"⑤ 风报比:止损{sl} 止盈{tp} RR={rr:.1f}") + # 支撑阻力 + sr_m = re.search(r'强撑:([\d.]+).*?弱撑:([\d.]+).*?弱压:([\d.]+).*?强压:([\d.]+)', tech) + if sr_m: + lines.append(f"⑥ 支撑阻力:强撑{sr_m.group(1)}→弱撑{sr_m.group(2)}→弱压{sr_m.group(3)}→强压{sr_m.group(4)}") + if sector: + lines.append(f"⑦ 行业背景:{sector}") + else: + # 从stock_sectors表补行业 + try: + _s2 = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _sr = _s2.execute("SELECT sector_name FROM stock_sectors WHERE code=? LIMIT 1", (code,)).fetchone() + if _sr and _sr[0]: + lines.append(f"⑦ 行业背景:{_sr[0]}") + _s2.close() + except: + pass + + # 消息面:从signal_news读最新信号 + news_lines = [] + try: + _n_db = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _nr = _n_db.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (sector LIKE ? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 2", + (f'%{code}%', f'%{name[:4]}%') + ).fetchall() + for _ns in _nr: + _sent = _ns[1] + _icon = '📈' if '利好' in str(_sent) else '📉' + news_lines.append(f"{_icon} {_ns[0][:60]} ({str(_ns[2])[:10]})") + _n_db.close() + except: + pass + if category: + lines.append(f"⑧ 分类评级:{category}") + lines.append(f"⑨ 策略信号:{signal}") + if news_lines: + lines.append("") + lines.extend(news_lines) + if act: + lines.append(f"\n策略详情:{act[:200]}") + + return "\n".join(lines) + + +def main(): + codes = [a for a in sys.argv[1:] if not a.startswith("-")] + if not codes: + print("[FULL] 无指定编码,跑全量 regenerate_all()") + from strategy_lifecycle import regenerate_all + regenerate_all(stdout=False) + print("[FULL] 全量重评完成") + return + + # 读现有 decisions + raw = read_decisions() + decisions_map = {d["code"]: d for d in raw.get("decisions", []) if d.get("code")} + + ok = 0 + errors = 0 + skipped = 0 + for code in codes: + # 冷却期检查 + if _in_cooldown(code): + print(f" ⏭ {code}: 冷却期内跳过") + skipped += 1 + continue + entry = decisions_map.get(code) + if not entry: + # 不在 decisions 中的自选股 → 从 holding_strategies 构建entry + import sqlite3 + _db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + _db.row_factory = sqlite3.Row + _wl = _db.execute("SELECT * FROM holding_strategies WHERE code=? AND status='active' AND decision_type='自选策略'", (code,)).fetchone() + _db.close() + if _wl: + entry = { + "code": code, + "name": _wl["name"], + "price": _wl["price"] or 0, + "cost": 0, + "shares": 0, + "entry_low": _wl["entry_low"] or 0, + "entry_high": _wl["entry_high"] or 0, + "stop_loss": _wl["stop_loss"] or 0, + "take_profit": 0, + "action": "", + "type": "自选策略", + "is_watchlist": True, + "analysis": json.loads(_wl["analysis_json"]) if _wl["analysis_json"] else {} + } + print(f"[WL] {code} {_wl['name']}: 从自选表构建entry") + if not entry: + print(f"[SKIP] {code}: 不在 decisions 或 watchlist_stocks 中") + errors += 1 + continue + + try: + # Always fetch live price for accurate reassessment + price = 0 + try: + # 价格从 DB 读取(price_monitor 每2分钟更新,唯一价格入口) + code_raw = entry.get("code", "") + price = 0 + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code_raw,)).fetchone() + if row: + price = row['price'] or 0 + db.close() + if price > 0: + print(f" 实时价: {price} (来自DB)") + else: + # fallback to DB portfolio data + _pf_data = read_portfolio() + for _h in _pf_data.get("holdings", []): + if _h["code"] == code_raw: + price = float(_h.get("price", 0)) + break + if price <= 0: + price = entry.get("current_price") or entry.get("price") or 0 + except Exception as e: + print(f" 价格获取失败: {e}", file=sys.stderr) + price = entry.get("current_price") or entry.get("price") or 0 + + # Price diff debounce: skip reassessment if price changed < 1% since last update + last_price = entry.get("last_reassessed_price") or 0 + if last_price > 0 and price > 0: + diff_pct = abs(price - last_price) / last_price * 100 + if diff_pct < 1.0: + print(f" 价差仅{diff_pct:.2f}% (<1%),跳过重评(上次价={last_price},现价={price})") + skipped += 1 + continue + # 打印参数调试 + if entry is None: + print(f" DEBUG: code={code} ENTRY=NONE 跳过") + print(f" [SKIP] {code} 策略数据不存在") + skipped += 1 + continue + entry_action = str(entry.get('action') or '') + print(f" DEBUG: code={code} name={entry.get('name','')} price={price} cost={entry.get('cost')} shares={entry.get('shares')} action={entry_action[:30]} is_wl={entry.get('type','') in ('自选策略','watchlist')}", flush=True) + result = reassess_strategy( + code=code, + name=entry.get("name", ""), + price=price or 0, + cost=entry.get("cost") or 0, + shares=entry.get("shares") or 0, + current_action=entry.get("action", ""), + is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), + ) + if result and result.get("action"): + # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 + is_held = (entry.get("cost") or 0) > 0 and (entry.get("shares") or 0) > 0 and \ + entry.get("type", "") not in ("自选策略", "watchlist") + old_stop = entry.get("stop_loss") or 0 + new_stop = result.get("stop_loss") or 0 + if is_held and old_stop > 0 and new_stop > 0 and new_stop < old_stop: + print(f" 移动止损保护: {new_stop}→保持{old_stop} (持仓止损不下移)") + result["stop_loss"] = old_stop + # 同时更新 action 字符串中的止损值 + act = result.get("action", "") + if act: + act = re.sub(r'止损[\d.]+', f'止损{old_stop}', act) + result["action"] = act + + # ── 写入 DB holding_strategies 表(替代 decisions.json)── + try: + from mofin_db import get_conn, write_holding_strategy + _conn = get_conn() + _db_entry = { + "code": code, + "name": entry.get("name", ""), + "price": price, + "cost": entry.get("cost", 0), + "shares": entry.get("shares", 0), + "stop_loss": result.get("stop_loss", entry.get("stop_loss")), + "take_profit": result.get("take_profit", entry.get("take_profit")), + "entry_low": result.get("entry_low", entry.get("entry_low")), + "entry_high": result.get("entry_high", entry.get("entry_high")), + "currency": "HKD" if (len(str(code)) == 5 and str(code)[0] in '01') else "CNY", + "strategy_type": "自选策略" if entry.get("type", "") in ("自选策略", "watchlist") else "持仓策略", + "action": result.get("action", ""), + "timing_signal": result.get("timing_signal", entry.get("timing_signal", "")), + "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), + "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot", "")), + "stock_category": result.get("stock_category", entry.get("stock_category", "")), + "sector_context": result.get("sector_context", entry.get("sector_context", "")), + "status": result.get("status", "active"), + "source": entry.get("source", "auto"), + "reason": result.get("action_note", ""), + "version": entry.get("version", 1), + "full_analysis": _build_full_analysis(code, entry, result) if result else "", + } + write_holding_strategy(_conn, code, entry.get("name", ""), _db_entry) + _conn.commit() + _conn.close() + # 验证写入 + _fa_check = _db_entry.get("full_analysis", "") + print(f" DEBUG: full_analysis长度={len(_fa_check)} 内容=[{_fa_check[:100]}]") + # 直接用SQL写入full_analysis + try: + _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _fa_conn.execute("UPDATE holding_strategies SET full_analysis=? WHERE code=? AND status='active'", (_fa_check, code)) + _fa_conn.commit() + _fa_conn.close() + print(f" ✅ full_analysis直接SQL写入成功") + except Exception as _fa_e: + print(f" ⚠️ 直接SQL写入失败: {_fa_e}") + _v = __import__('sqlite3').connect(str(__import__('pathlib').Path("/home/hmo/MoFin/data/mofin.db"))) + _fa = _v.execute("SELECT full_analysis FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _fa and _fa[0]: print(f" ✅ full_analysis已写入({len(_fa[0])}字)") + else: print(f" ⚠️ full_analysis为空") + _v.close() + # LLM生成完整九维分析 + _macro_desc = "" + _pe_val = "" + _pb_val = "" + try: + _mdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _mr = _mdb.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if _mr and _mr[0]: + _st = __import__('json').loads(_mr[0]) + _macro_desc = _st.get("description", "") + _mood = _mr[1] if len(_mr) > 1 else "" + if _mood: _macro_desc += f" 情绪={_mood}" + # 基本面从腾讯API + _p = "sh" if str(code).startswith(("6","9")) else "sz" + _cr = __import__('subprocess').run(["curl","-s",f"http://qt.gtimg.cn/q={_p}{code}"], capture_output=True, timeout=10) + _ct = _cr.stdout.decode("gbk", errors="ignore").split("~") + if len(_ct) > 39 and _ct[39]: _pe_val = f"PE={_ct[39]}" + if len(_ct) > 44 and _ct[44]: _pb_val = f"PB≈{float(_ct[44])/10000:.1f}亿" + _mdb.close() + except: + pass + + # 拉取资金流数据 + _flow_note = "暂无资金流数据" + try: + _fdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _fr = _fdb.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + if _fr and _fr[0]: + _fc = __import__('json').loads(_fr[0]) + _s = _fc.get("stocks", {}).get(code, {}) + if _s and _s.get("analysis"): + _a = _s["analysis"] + _flow_note = f"净流入{_a.get('net_flow',0):.0f}万 主力{_a.get('main_force',0):.0f}万 趋势{_a.get('trend','中性')}" + _fdb.close() + except: + pass + + # 拉取近期消息面 + _news_note = "暂无近期消息" + try: + _ndb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _nr2 = _ndb.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 3", + (code, f'%{entry.get("name","")[:4]}%') + ).fetchall() + if _nr2: + _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr2]) + _ndb.close() + except: + pass + + # ── 拉取已有策略全文 + 最近变更 ── + _existing_full_analysis = "" + _existing_changelog_text = "无变更记录" + try: + _edb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _er = _edb.execute( + "SELECT full_analysis, changelog_json FROM holding_strategies " + "WHERE code=? AND status='active'", (code,) + ).fetchone() + if _er: + _existing_full_analysis = _er[0] or "" + _cl_raw = _er[1] or "" + if _cl_raw: + _cl = __import__('json').loads(_cl_raw) if isinstance(_cl_raw, str) else _cl_raw + if isinstance(_cl, list) and _cl: + _recent = _cl[-3:] + _existing_changelog_text = "\n".join( + [f" [{c.get('timestamp','?')}] {c.get('action','?')}: {c.get('reason','')}"[:120] + for c in reversed(_recent)] + ) + _edb.close() + except: + pass + + _prompt = f"""你是一个资深股票分析师。请对股票{code}评估现有策略是否仍然有效,并输出完整的新策略。 + +╔══════════════════════════════════════════════╗ +║ 📋 第一步:审阅原策略 ║ +╚══════════════════════════════════════════════╝ + +【原策略全文】(上次完整分析): +{_existing_full_analysis or '暂无完整策略分析'} + +【当前策略参数】: + 价格={price} 信号={result.get("timing_signal") or entry.get("timing_signal","")} + 买入区间={entry.get("entry_low",0)}~{entry.get("entry_high",0)} + 止损={entry.get("stop_loss",0)} 止盈={entry.get("take_profit",0)} + RR={result.get("rr_ratio", entry.get("rr_ratio", 0))} + 策略={result.get("action") or entry.get("action","")} + 行业={(result.get("sector_context") or entry.get("sector_context",""))[:50]}(当日实时) + 技术={(result.get("tech_snapshot") or entry.get("tech_snapshot",""))[:200]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) + +【最近变更记录】: +{_existing_changelog_text} + +╔══════════════════════════════════════════════╗ +║ 📊 第二步:12维矩阵交叉分析 ║ +╚══════════════════════════════════════════════╝ + +⚠️ 重要:12个维度必须交叉对比,找出矛盾/共振点,给出综合判断。 + +当前实时数据(每条标注时间窗口,禁止使用模型训练数据): +大盘={_macro_desc or "震荡"}(当日实时) | PE/市值={_pe_val} {_pb_val}(最新财报) +资金流={_flow_note}(近5日累计) +消息面={_news_note}(最近3条,自动标注抓取时间) + +╔══════════════════════════════════════════════╗ +║ 📝 第三步:决策输出 ║ +╚══════════════════════════════════════════════╝ + +请严格按以下顺序输出: + +【维持或修改】判断当前策略是否仍然有效,回答「维持」或「修改」。 + +【修改点及理由】(如果维持,写「无需修改」;如果修改,逐条列出): + - 修改什么参数/方向 + - 理由(引用具体维度矛盾或共振) + +【最终新策略】(完整策略全文,self-contained,可直接存入DB) + +【交叉分析】哪些维度矛盾/共振,关键信号 +① 大盘×基本面 ② 大盘×消息面 ③ 大盘×技术面 ④ 大盘×资金面 +⑤ 行业×基本面 ⑥ 行业×消息面 ⑦ 行业×技术面 ⑧ 行业×资金面 +⑨ 个股×基本面 ⑩ 个股×消息面 ⑪ 个股×技术面 ⑫ 个股×资金面 + +最后必须输出: +【综合结论】(买入/关注/观望/卖出) +【操作建议】 +【建议止损】 +【建议止盈】 +【建议仓位】⚠️不可省略,非"买入"时写"不新建仓" + +⚠️ 输出纪律(必须遵守): +1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 +2. 禁止输出 或任何 XML/JSON/代码块 +3. 所有【】节标题一个都不能少""" + _full_analysis_text = None + try: + _llm_result = call_llm(_prompt, max_tokens=4096, timeout=150, retries=1, backoff=20) + if _llm_result["ok"]: + _full_analysis_text = _llm_result["content"] + print(f" ✅ LLM12维分析完成({len(_full_analysis_text)}字, {_llm_result['elapsed']:.1f}s)", flush=True) + else: + print(f" ❌ LLM12维分析失败({_llm_result['attempts']}次): {_llm_result['error'][:200]}", flush=True) + except Exception as _e: + print(f" ❌ LLM12维分析异常: {_e}", flush=True) + + # ── 保存到DB(覆写前先快照)── + _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + if _full_analysis_text: + # 快照旧策略(使用共享函数) + try: + snapshot_strategy_history(_fa_conn, code, "per_stock_12d") + except Exception as _se: + print(f" ⚠️ 快照失败: {_se}", flush=True) + + _fa_conn.execute( + "UPDATE holding_strategies SET full_analysis=?, reassessed_at=? WHERE code=? AND status='active'", + (_full_analysis_text, __import__('datetime').datetime.now().isoformat(), code)) + _fa_conn.commit() + _fa_conn.close() + if _full_analysis_text: + print(f" ✅ 完整12维分析已保存({len(_full_analysis_text)}字)") + else: + print(f" ⚠️ 12维分析未完成,跳过保存") + print(f" [DB] holding_strategies 已更新: {code}") + # 从LLM输出提取信号 + if _full_analysis_text and '【综合结论】' in _full_analysis_text: + try: + _sig_line = [l for l in _full_analysis_text.split('\n') if '综合结论' in l] + if _sig_line: + _sig = '买入' if '买入' in _sig_line[0] else '关注' if '关注' in _sig_line[0] else '观望' if '观望' in _sig_line[0] else '卖出' if '卖出' in _sig_line[0] else '' + if _sig: + _ts_conn = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db') + _ts_conn.execute( + "UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", (_sig, code)) + _ts_conn.commit() + # 推荐操作 tag 同步(与 XMPP 动作级信号同源) + from mofin_db import sync_recommend_tag + sync_recommend_tag(_ts_conn, code, _sig) + _ts_conn.close() + print(f" ✅ LLM信号={_sig} 已写入") + # 买入信号→推XMPP + if _sig == "买入": + try: + _nr2 = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db').execute( + "SELECT name, price, entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _nr2: + _xm = f"📈 {_nr2[0] or code}({code}) 价{_nr2[1]}→12维买入信号!区间{_nr2[2]}~{_nr2[3]} 损{_nr2[4]} 盈{_nr2[5]} 仓位{_nr2[6] or '-'}" + from alert_helper import notify as _notify2, ACTION as _ACT2 + _notify2("买入信号", _xm, _ACT2) + print(f" 📨 XMPP推送买入信号") + except: pass + except: pass + # 冷却期已更新(reassessed_at写入) + except Exception as _dbe: + print(f" [DB FAIL] holding_strategies 写入失败: {_dbe}", file=sys.stderr) + + # 更新 decisions_map 中对应的条目 + updated = entry.copy() + # 币种标记:HK股保留HKD原始值,A股为CNY + is_hk = len(str(code)) == 5 and str(code)[0] in '01' + updated.update({ + "action": result["action"], + "stop_loss": result.get("stop_loss", entry.get("stop_loss")), + "entry_low": result.get("entry_low", entry.get("entry_low")), + "entry_high": result.get("entry_high", entry.get("entry_high")), + "take_profit": result.get("take_profit"), + "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot")), + "timing_signal": result.get("timing_signal", entry.get("timing_signal")), + "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), + "status": result.get("status", "updated"), + "price": price, + "currency": "HKD" if is_hk else "CNY", + }) + # Save last reassessed price for debounce tracking + updated["last_reassessed_price"] = price + decisions_map[code] = updated + # ——— 初始化多分支策略树 ——— + try: + sys.path.insert(0, '/home/hmo/MoFin') + from strategy_tree import init_default_branches + branches = init_default_branches( + code, + entry.get('name', ''), + result.get('entry_low', 0), + result.get('entry_high', 0), + result.get('stop_loss', 0), + result.get('take_profit', 0), + ) + st = updated.setdefault('strategy_tree', {}) + st['branches'] = branches + except Exception: + pass + print(f"[OK] {code} {entry.get('name','')}: {result['action'][:80]}") + ok += 1 + else: + print(f"[SYNCED] {code}: 无变更") + ok += 1 + except Exception as e: + print(f"[ERROR] {code}: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + errors += 1 + + # 同步自选股更新回 watchlist_stocks 表(持仓策略已通过 write_holding_strategy 写入 DB) + try: + from datetime import datetime as _dt + import sqlite3 + _db2 = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + for _code in codes: + _entry = decisions_map.get(_code) + if _entry and _entry.get("is_watchlist"): + _db2.execute(""" + UPDATE watchlist_stocks + SET entry_low=?, entry_high=?, stop_loss=?, price=?, + analysis_json=json(?) + WHERE code=? AND is_active=1 + """, ( + _entry.get("entry_low", 0), + _entry.get("entry_high", 0), + _entry.get("stop_loss", 0), + _entry.get("price", 0), + json.dumps({ + "action": _entry.get("action",""), + "take_profit": _entry.get("take_profit", 0), + "stop_loss": _entry.get("stop_loss", 0), + "tech_snapshot": _entry.get("tech_snapshot", ""), + "rr": _entry.get("rr_ratio", 0), + "reassessed_at": _dt.now().strftime("%Y-%m-%d") + }, ensure_ascii=False), + _code + )) + _db2.commit() + _db2.close() + if any(e.get("is_watchlist") for e in [decisions_map.get(c) for c in codes] if e): + print("[SYNC] 自选股策略已同步回 watchlist_stocks 表") + except Exception as e: + print(f"[SYNC FAIL] watchlist_stocks 同步失败: {e}", file=sys.stderr) + + print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") + + # ── 第二步:扫描自选股(watchlist),价格偏离买入区>20%触发重评 ── + scan_watchlist_stocks() + + +# ════════════════════════════════════════════════════════════════════ +# 自选股扫描 +# ════════════════════════════════════════════════════════════════════ + +def scan_watchlist_stocks(): + """扫描自选股表 (watchlist_stocks),对价格偏离买入区 >20% 的股票自动重评。 + + 偏离公式: max(|price - entry_low|, |price - entry_high|) / entry_low * 100 > 20 + + 通过 technical_analysis.full_analysis() 获取最新支撑/阻力位, + 更新 entry_low / entry_high / stop_loss / price / analysis_json。 + 每轮最多处理 3 只,超过时标记剩余数量待下次扫描。 + """ + import sqlite3, json + from datetime import datetime + from technical_analysis import full_analysis + from mo_models import is_hk_stock + + DB = '/home/hmo/web-dashboard/data/mofin.db' + db = sqlite3.connect(DB) + db.row_factory = sqlite3.Row + + rows = db.execute( + "SELECT * FROM watchlist_stocks WHERE is_active=1" + ).fetchall() + + if not rows: + print("[WL-SCAN] 自选股表为空,跳过") + db.close() + return + + # ── 筛选偏离 >20% 的股票 ── + candidates = [] # (code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json) + for r in rows: + code = r["code"] + name = r["name"] + price = r["price"] or 0 + entry_low = r["entry_low"] or 0 + entry_high = r["entry_high"] or 0 + stop_loss = r["stop_loss"] or 0 + analysis_json = r["analysis_json"] + + if entry_low <= 0 or price <= 0: + continue + + dev_low = abs(price - entry_low) + dev_high = abs(price - entry_high) + deviation = max(dev_low, dev_high) / entry_low * 100 + + if deviation > 20: + candidates.append((code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json)) + + total_needed = len(candidates) + print(f"[WL-SCAN] 自选股共{len(rows)}只,偏离>20%需重评: {total_needed}只") + + MAX_PER_RUN = 3 + to_process = candidates[:MAX_PER_RUN] + remaining = max(0, total_needed - MAX_PER_RUN) + + if remaining > 0: + print(f"[WL-SCAN] 本轮限{MAX_PER_RUN}只,剩余{remaining}只待下次扫描") + + if not to_process: + print("[WL-SCAN] 无需重评") + db.close() + return + + ok = 0 + errors = 0 + for code, name, price, old_low, old_high, old_stop, deviation, old_analysis_json in to_process: + print(f"[WL-REASSESS] {code} {name}: 偏离{deviation:.1f}%,触发重评") + try: + ta = full_analysis(code) + if not ta or "error" in ta: + print(f" [WARN] TA失败: {ta}") + errors += 1 + continue + + sr = ta.get("support_resistance", {}) + if "error" in sr: + print(f" [WARN] 支撑/阻力计算失败: {sr}") + errors += 1 + continue + + new_price = ta.get("quote", {}).get("price", price) + new_entry_low = round(sr.get("weak_support", old_low), 2) + new_entry_high = round(sr.get("weak_resist", old_high), 2) + new_stop_loss = round(sr.get("strong_support", old_stop), 2) + new_take_profit = round(sr.get("strong_resist", 0), 2) + + # ── 更新 analysis_json + changelog ── + old_analysis = json.loads(old_analysis_json) if old_analysis_json else {} + changelog = old_analysis.get("changelog", []) + changelog.append({ + "action": "auto_reassess_watchlist", + "reason": f"价格偏离买入区{deviation:.1f}%", + "old_entry_low": old_low, + "old_entry_high": old_high, + "new_entry_low": new_entry_low, + "new_entry_high": new_entry_high, + "old_stop_loss": old_stop, + "new_stop_loss": new_stop_loss, + "take_profit": new_take_profit, + "price": new_price, + "deviation_pct": round(deviation, 1), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + }) + + new_analysis = { + **old_analysis, + "take_profit": new_take_profit, + "tech_snapshot": { + "support_resistance": sr, + "candlestick": ta.get("candlestick", {}), + "volume": ta.get("volume", {}), + "analyzed_at": ta.get("analyzed_at", ""), + }, + "reassessed_at": datetime.now().strftime("%Y-%m-%d"), + "changelog": changelog, + } + + currency = "HKD" if is_hk_stock(code) else "CNY" + + db.execute(""" + UPDATE watchlist_stocks + SET entry_low=?, entry_high=?, stop_loss=?, price=?, + currency=?, analysis_json=? + WHERE code=? AND is_active=1 + """, ( + new_entry_low, new_entry_high, new_stop_loss, + new_price, currency, json.dumps(new_analysis, ensure_ascii=False), + code, + )) + db.commit() + print(f" [OK] {code} {name}: 买入区{old_low}-{old_high} -> {new_entry_low}-{new_entry_high}, " + f"止损{new_stop_loss}, 止盈{new_take_profit}") + ok += 1 + except Exception as e: + import traceback + print(f" [ERROR] {code}: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + errors += 1 + + db.close() + remaining_msg = f" (剩余{remaining}只)" if remaining else "" + print(f"[WL-SCAN] DONE: {ok}成功 {errors}失败{remaining_msg}") + + +if __name__ == "__main__": + main() diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py new file mode 100644 index 00000000..25f5cd92 --- /dev/null +++ b/scripts/price_monitor.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import urllib.request +import os, sys, time, json +import sqlite3 +from datetime import datetime + +from mo_data import read_decisions + +BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" +STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" + +# DB 模块(同步实时价到 mofin.db) +sys.path.insert(0, "/home/hmo/MoFin") +try: + from mofin_db import get_conn, DB_PATH + from mo_models import calc_total_mv, calc_total_assets + HAS_DB = True +except ImportError: + HAS_DB = False + +# 策略重评依赖(技术面驱动,非机械百分比) +sys.path.insert(0, "/home/hmo/web-dashboard") +try: + from strategy_lifecycle import reassess_strategy, reassess_with_context + HAS_REASSESS = True +except ImportError: + HAS_REASSESS = False + +UA = "Mozilla/5.0" + +# ── XMPP推送 ────────────────────────────────────────────────────────── +XMPP_USER = "hmo@yoin.fun" +XMPP_BRIDGE = "http://127.0.0.1:5805/" + +def push_to_xmpp(text): + """原始直推(已废弃直用)——保留给极少数必须原样的场景。 + 新代码请用 _push_action/_push_digest。""" + 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) + + +# ── 分级推送(2026-07-21 信噪比纪律,红线#12)── +# ACTION: 破止损/重评确认的操作信号 — 直通不限速 +# INFO: 未确认的进区提示 — 聚合成摘要,30min 限 1 条 +def _push_action(category, text): + try: + from alert_helper import notify, ACTION + notify(category, text, ACTION) + except Exception as e: + print(f"[ACTION推送失败] {e}", file=sys.stderr) + + +def _push_digest(category, text): + try: + from alert_helper import notify, INFO + notify(category, text, INFO) + except Exception as e: + print(f"[INFO推送失败] {e}", file=sys.stderr) + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:一次请求拉取所有股票(A股+港股) + A股:sh600110 / sz000001 + 港股:hk00700 + 返回 {code: (price, change, change_pct)} + """ + if not codes: + return {} + + # 构建批量查询串 + symbols = [] + code_map = {} # symbol -> original_code + for code in codes: + code_s = str(code).strip() + if len(code_s) == 6: + # A股:沪市以5/6/9开头,深市以0/3开头 + if code_s.startswith(('5', '6', '9')): + sym = f"sh{code_s}" + else: + sym = f"sz{code_s}" + else: + sym = f"hk{code_s}" + symbols.append(sym) + code_map[sym] = code_s + + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + except Exception as e: + print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr) + return {} + + results = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + try: + # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." + raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw_value.split("~") + if len(fields) < 6: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig_code = code_map.get(sym) + if not orig_code: + continue + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[4]) if fields[4] else 0 + change = price - prev_close if prev_close > 0 else 0 + change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" + results[orig_code] = (price, change, change_pct) + except (ValueError, IndexError): + continue + + return results + + +def refresh_data_prices(): + """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)""" + all_codes = set() + + # 从DB读所有需要拉取价格的代码 + try: + conn = get_conn() + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + all_codes.add(r['code']) + for r in conn.execute("SELECT code FROM watchlist_stocks"): + all_codes.add(r['code']) + for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): + all_codes.add(r['code']) + conn.close() + except Exception as e: + print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr) + return 0 + + if not all_codes: + return 0 + + # 一次性批量拉取 + prices = fetch_all_prices(list(all_codes)) + updated = len(prices) + + # === 弹性同步实时价到 mofin.db === + # 防死锁策略(经2026-07-14 WAL死锁复盘改进): + # ① 启动时 checkpoint WAL(清理残留事务) + # ② 统一 BEGIN IMMEDIATE 包裹整个写操作 + # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s) + # ④ get_conn() 的 busy_timeout=30000 保证等待上限 + # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试 + # ⑥ try/finally 确保连接始终释放 + if HAS_DB and prices: + # 先checkpoint一次,清理上次被kill残留的WAL + try: + c = get_conn() + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + except Exception: + pass + + max_tries = 5 + conn = None + for db_attempt in range(max_tries): + try: + conn = get_conn() + # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s) + conn.execute("BEGIN IMMEDIATE") + + # ── 构建 holdings 更新数据 ── + db_holdings = [] + for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"): + h = dict(r) + code = str(h.get('code', '')) + if code in prices: + price_val, _, change_pct = prices[code] + if price_val > 0: + h['price'] = round(price_val, 2) + h['change_pct'] = float(change_pct) if change_pct else 0 + db_holdings.append(h) + + # ── 写 holdings 表 ── + for h in db_holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + raise ValueError(f"非法币种: {currency}") + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + + # ── 写 portfolio_summary ── + mv = calc_total_mv(db_holdings) + existing = conn.execute( + 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' + ).fetchone() + db_cash = existing['cash'] if existing else 0.0 + db_frozen = existing['frozen_cash'] if existing else 0.0 + assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) + position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + assets, mv, mv, db_cash, db_frozen, + position_pct, 0, 'CNY', + )) + + # ── 写 live_prices ── + for h in db_holdings: + code = h.get('code', '') + if code: + p = h.get('price', 0) + cp = h.get('change_pct', 0) + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " + "VALUES (?,?,?,datetime('now','localtime'))", + (code, p, cp) + ) + # 补充策略股/自选股的价格(不在holdings中的) + for code, pdata in prices.items(): + if code not in {h.get('code') for h in db_holdings}: + price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0) + cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0) + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " + "VALUES (?,?,?,datetime('now','localtime'))", + (code, price_val, cp_val) + ) + + conn.commit() + conn.close() + conn = None + if db_attempt > 0: + print(f"DB同步成功(第{db_attempt+1}次重试)") + break # success + + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + if conn: + try: conn.rollback() + except Exception: pass + try: conn.close() + except Exception: pass + conn = None + err_str = str(e) + if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str: + if db_attempt < max_tries - 1: + wait = 2 ** db_attempt # 1, 2, 4, 8, 16 + print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr) + time.sleep(wait) + else: + print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr) + else: + print(f"❌ DB错误: {e}", file=sys.stderr) + break + except Exception as e: + if conn: + try: conn.rollback() + except Exception: pass + try: conn.close() + except Exception: pass + conn = None + print(f"⚠️ DB同步异常: {e}", file=sys.stderr) + break + else: + # for-else: loop exhausted without break + print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr) + # 尝试紧急 WAL checkpoint(释放死锁) + try: + c = sqlite3.connect(str(DB_PATH), timeout=1) + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr) + except Exception as we: + print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr) + + return updated + + +# ── 区间偏离检测 ────────────────────────────────────────────────────────── + +def load_state(): + try: + with open(STATE_PATH) as f: + return json.load(f) + except: + return {} + +def save_state(state): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, 'w') as f: + json.dump(state, f, ensure_ascii=False, indent=2) + +def load_breaches(): + try: + with open(BREACH_PATH) as f: + return json.load(f) + except: + return {} + +def save_breaches(data): + os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) + with open(BREACH_PATH, 'w') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def record_event(code, name, event_type, price, trigger_value, event_label=""): + """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 + + price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) + 先注册再写事件,否则 FK 失败事件丢失。 + """ + now = datetime.now().isoformat() + + if HAS_DB: + try: + from mofin_db import get_conn, write_price_event + _c = get_conn() + _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A")) + _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)", + (str(code), name or str(code), _exch, _typ, now)) + _c.commit() + write_price_event(_c, code=code, name=name, event_type=event_type, + price=round(price, 2), trigger_value=str(trigger_value), + event_label=event_label) + _c.close() + except Exception as e: + print(f"[price_events DB写入失败] {e}", file=sys.stderr) + + +def get_trigger_zones(trigger): + """返回该trigger所有可监控的区间列表,跳过已执行的batch""" + zones = [] + for key, label in [ + ("entry_zone", "加仓区间"), + ("batch1_price", "试仓区间"), + ("batch2_price", "加仓区间"), + ("take_profit_zone", "止盈区间"), + ("watch_low", "关注区间"), + ("watch_high", "减仓区间"), + ("watch_break", "止损区间") + ]: + status_key = key.replace("_price", "_status") + if status_key in trigger and trigger[status_key] == "executed": + continue + val = trigger.get(key, "") + if val and "~" in val: + try: + parts = val.split("~") + lo, hi = float(parts[0]), float(parts[1]) + zones.append((key, label, lo, hi)) + except: + pass + sl = trigger.get("stop_loss", "") + if sl: + try: + sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) + zones.append(("stop_loss", "止损", 0, sl_price)) + except: + pass + return zones + + +def _cleanup_lock(): + """清理进程锁文件""" + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + +def _handle_sigterm(signum, frame): + """收到SIGTERM时清理锁文件后退出""" + _cleanup_lock() + sys.exit(0) + +def _handle_sigalrm(signum, frame): + """收到SIGALRM强制超时时清理锁文件后退出""" + _cleanup_lock() + print(f"[TIMEOUT] 本轮执行超时({signum}s),已清理锁文件", file=sys.stderr, flush=True) + sys.exit(0) + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖 + signal.signal(signal.SIGTERM, _handle_sigterm) + signal.signal(signal.SIGALRM, _handle_sigalrm) + 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())) + signal.alarm(120) # 硬上限120s,超时自动清理锁退出 + + 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)) + + # 本轮进区事件收集(聚合成一条摘要推送,替代逐条轰炸) + _zone_entries = [] + + 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_action("止损告警", 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_action("操作信号", 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): + _zone_entries.append(f"{name}({code}) {price}→{label}{lo}~{hi}") + outputs.append(f" 📨 区间触发(超时)→记入摘要") + 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_action("操作信号", 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 + + # === 第二步收尾:进区事件聚合成一条摘要推送(INFO级,30min限1条+截断)=== + if _zone_entries: + _digest = f"📋 {len(_zone_entries)}只进入操作区:\n" + "\n".join(f"• {e}" for e in _zone_entries) + _push_digest("盘中触发", _digest) + outputs.append(f"📨 进区摘要({len(_zone_entries)}只)→已按INFO策略推送") + + # === 第三步:买入区偏离检测 + 自动重评 === + reassesed_codes = [] + # 先做急跌检测(仅持仓,自选股不推送暴跌告警) + holdings_codes = set() + for d in active: + shares = d.get("shares", 0) + if isinstance(shares, (int, float)): + if shares > 0: + holdings_codes.add(d["code"]) + else: + # 非数值shares(如被错误写入的字符串),兜底处理 + holdings_codes.add(d["code"]) + print(f" [WARN] {d.get('code')} shares为非数值({shares!r}),视为持仓处理", flush=True) + 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_action("急跌告警", 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) + + # 取消超时定时器(正常完成) + signal.alarm(0) + + # 清理进程锁 + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/scripts/stale_detector.py b/scripts/stale_detector.py new file mode 100644 index 00000000..fc805b83 --- /dev/null +++ b/scripts/stale_detector.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 + +读取 holding_strategies + 自选策略的DB双源数据。 +可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 + +输出格式: + [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 + +用法: + python3 stale_detector.py +""" +import json +import sys +import os +from datetime import datetime, timezone +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions, read_watchlist, get_price, get_prices_batch + + +def fetch_prices(codes): + """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" + if not codes: + return {} + # 尝试用 stock_quote.py 获取(脚本强制规范) + try: + import subprocess + script = None + for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: + if os.path.exists(p): + script = p + break + if script: + result = subprocess.run( + [sys.executable, script] + [str(c) for c in codes], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + results = {} + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + try: + item = json.loads(line) + code = str(item.get("code", "")) + price = item.get("price") + change = item.get("change_pct", 0) + if code and price is not None: + results[code] = (float(price), float(change)) + except (json.JSONDecodeError, ValueError): + continue + if results: + return results + except Exception as e: + print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) + + # 兜底:mo_data.get_prices_batch + try: + raw = get_prices_batch(codes) + if raw: + return {code: (p, chg) for code, (p, chg) in raw.items()} + except Exception as e: + print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) + return {} + + +def main(): + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + + # 只保留有买入区的条目,排除已关闭的(inactive/closed) + EXCLUDED_STATUSES = ("closed", "inactive") + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + + # ----- 补充自选(从 holding_strategies 读取,watchlist_stocks 已废弃) ----- + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db.row_factory = sqlite3.Row + wl_rows = db.execute( + "SELECT code, name, entry_low, entry_high, stop_loss, take_profit, rr_ratio, timing_signal, action " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL" + ).fetchall() + db.close() + existing_codes = {d["code"] for d in to_check} + for row in wl_rows: + code = str(row["code"]) + if code in existing_codes: + continue + entry_low = row["entry_low"] + entry_high = row["entry_high"] + if not entry_low or not entry_high or entry_low <= 0: + continue + action = row["action"] or "" + timing_signal = row["timing_signal"] or "买入" + wl_entry = { + "code": code, + "name": row["name"] or code, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": row["stop_loss"], + "type": "自选策略", + "action": action, + "timing_signal": timing_signal, + } + to_check.append(wl_entry) + except Exception as e: + print(f"[WATCHLIST_MERGE FAIL] {e}", file=sys.stderr) + + if not to_check: + print("[SILENT] 无需要检查的策略") + return 0 + + # ----- 自选股买入区偏离自动重评 (从 holding_strategies 读,watchlist_stocks 已废弃) ----- + try: + import subprocess, sqlite3 + db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db.row_factory = sqlite3.Row + wl_stocks = db.execute( + "SELECT code, name, entry_low, entry_high " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL AND entry_low > 0" + ).fetchall() + db.close() + reassess_scripts = [] + for ws in wl_stocks: + code, name, wl_el, wl_eh = ws + if not wl_el or not wl_el or wl_el <= 0: + continue + center = (wl_el + wl_eh) / 2 + # 从 decisions 拿实时价 + price_map = fetch_prices([code]) + cur_price = price_map.get(code, (None, None))[0] + if not cur_price or cur_price <= 0: + continue + drift = (cur_price / center - 1) * 100 + # 触发条件:价格偏离>15% 或 买入区明确错误(价格完全在区间外且偏离>50%) + price_outside = cur_price < wl_el or cur_price > wl_eh + if abs(drift) > 15 or (price_outside and abs(drift) > 50): + reassess_scripts.append(code) + print(f"[AUTO_REASSESS] {name}({code}) 价{cur_price:.2f}偏离买入区中心{center:.2f} {drift:+.0f}% → 触发重评") + if reassess_scripts: + # 调用 per_stock_reassess(每轮最多5只,防LLM慢导致整批超时;其余下轮继续) + reassess_path = None + for p in ['/home/hmo/MoFin/scripts/per_stock_reassess.py', + '/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py']: + if os.path.exists(p): + reassess_path = p + break + if reassess_path: + MAX_PER_RUN = 5 + batch = reassess_scripts[:MAX_PER_RUN] + if len(reassess_scripts) > MAX_PER_RUN: + print(f"[AUTO_REASSESS] 本轮限{MAX_PER_RUN}只,剩余{len(reassess_scripts)-MAX_PER_RUN}只下轮继续") + for code in batch: + try: + # LLM 重评冷启动 20-100s,deepseek-v4-pro 更慢 → 480s + r = subprocess.run(['python3', reassess_path, code], + capture_output=True, text=True, timeout=480) + out = r.stdout.strip()[:200] if r.stdout else "" + err = r.stderr.strip()[:200] if r.stderr else "" + print(f" → {code}: exited={r.returncode} {out}") + except subprocess.TimeoutExpired: + print(f" → {code}: 超时480s(LLM仍慢),下轮重试") + except Exception as e: + print(f"[AUTO_REASSESS FAIL] {e}") + # ----- 结束 自选股重评 ----- + # 🔁 重评后重新从DB读取策略数据,刷新to_check + try: + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + # 重新合并自选(从 holding_strategies 读) + db2 = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db2.row_factory = sqlite3.Row + wl_rows2 = db2.execute( + "SELECT code, name, entry_low, entry_high, stop_loss, take_profit, rr_ratio, timing_signal, action " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL AND entry_low > 0" + ).fetchall() + db2.close() + existing_codes2 = {d["code"] for d in to_check} + for row in wl_rows2: + code = str(row["code"]) + if code in existing_codes2: + continue + entry_low = row["entry_low"] + entry_high = row["entry_high"] + if not entry_low or not entry_high or entry_low <= 0: + continue + action = row["action"] or "" + timing_signal = row["timing_signal"] or "买入" + wl_entry = { + "code": code, + "name": row["name"] or code, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": row["stop_loss"], + "type": "自选策略", + "action": action, + "timing_signal": timing_signal, + } + to_check.append(wl_entry) + except Exception as e: + print(f"[RELOAD FAIL] {e}", file=sys.stderr) + + # ----- 组合级监测:读取总仓位 + 弱势比例 ----- + position_pct = 0 + cash = 0 + total_assets = 0 + try: + pf = read_portfolio() + position_pct = pf.get("position_pct", 0) + cash = pf.get("cash", 0) + total_assets = pf.get("total_assets", 0) + except Exception: + pass + # 统计持仓策略中弱势/深套的比例 + weak_count = 0 + holding_count = 0 + for d in decisions_list: + if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): + holding_count += 1 + cat = d.get("stock_category", "") + if cat in ("弱势", "深套"): + weak_count += 1 + weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 + + prices = fetch_prices([d["code"] for d in to_check]) + now = datetime.now(timezone.utc).astimezone() + found = 0 + + for d in to_check: + code = d["code"] + name = d.get("name", code) + el = d.get("entry_low") + eh = d.get("entry_high") + sl = d.get("stop_loss") + tp = d.get("take_profit") + ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") + is_wl = "自选" in (d.get("type", "")) + + pi = prices.get(code) + if not pi: + continue + price, chg = pi + if price <= 0: + continue + + issues, flags = [], [] + tag = "[自选]" if is_wl else "[持仓]" + + # -- 偏离 -- + if is_wl and not issues and not flags: + # 自选在买入区上沿与20%之间(零标记漏洞):标记为小幅偏离 + if el and eh and price > eh: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}超买入区上沿+{((price/eh)-1)*100:.1f}%,买入区需重评") + if is_wl and el and eh: + # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) + current_str = d.get("current", "") or "" + timing_signal = d.get("timing_signal", "") or current_str + has_nonbuy_signal = any(kw in timing_signal for kw in [ + "等企稳再入", "等企稳", "弱势持有", "观望", + "不建议买入", "谨慎买入", + ]) + + # 直接计算 R/R(不依赖文本匹配) + rr_invalid = False + if sl and sl > 0 and tp and tp > 0 and price > sl: + rr = (tp - price) / (price - sl) + if rr < 1.5: + rr_invalid = True + # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) + cost = d.get("cost", 0) + if cost and cost > 0 and tp <= cost * 1.05: + rr_invalid = True + + strategy_deficient = has_nonbuy_signal or rr_invalid + # 对自选无止盈位的也标记(策略不完整) + if not tp or tp == 0: + strategy_deficient = True + + if el <= price <= eh: + flags.append("[WL_IN]") + if strategy_deficient: + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") + else: + issues.append(f"[PUSH] 价{price:.2f}入买入区{el}~{eh}") + elif price > eh * 1.35: + flags.append("[WL_HIGH]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh * 1.20: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}超买入区上沿+{((price/eh)-1)*100:.1f}%,买入区需重评") + elif not is_wl and eh: + dp = (price / eh - 1) * 100 + if dp > 35: + flags.append("[SEVERE]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 20: + flags.append("[DRIFT]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 10: + flags.append("[WARN]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + # 持仓在买入区内但 R/R 不达标 + if el and sl and sl > 0 and tp and tp > 0 and price > sl: + if el <= price <= eh: + rr = (tp - price) / (price - sl) + if rr < 1.5: + flags.append("[RR_WARN]") + issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") + + # -- 距止损/止盈(仅持仓) -- + if not is_wl: + if sl and sl > 0: + dsl = (price / sl - 1) * 100 + if dsl < 5: + # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 + # (mirrors NEAR_TP cost_check logic at line 195-198) + cost = d.get("cost") + if cost and cost > 0 and price > cost * 1.05: + flags.append("[PROFIT_PROTECT]") + pnl = (price / cost - 1) * 100 + issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") + else: + flags.append("[NEAR_SL]") + issues.append(f"距止损仅{dsl:.1f}%") + if tp and tp > 0: + dtp = (tp / price - 1) * 100 + if dtp < 5: + # 成本基准校验:止盈标记只有在盈利≥5%时才有效 + cost_check = True + cost = d.get("cost") + if cost and cost > 0 and price < cost * 1.05: + cost_check = False + if cost_check: + flags.append("[NEAR_TP]") + issues.append(f"距止盈仅{dtp:.1f}%") + + # -- 过期 -- + stale_limit = 30 if is_wl else 14 + if ts: + try: + ud = datetime.fromisoformat(ts) + if ud.tzinfo is None: + ud = ud.replace(tzinfo=timezone.utc) + days = (now - ud).days + if days > stale_limit: + flags.append("[STALE]") + issues.append(f"{days}天未更新(>{stale_limit})") + except (ValueError, TypeError): + pass + + if issues: + # 仅输出有明确操作信号的行:[PUSH]=推荐买入, [STRATEGY_STALE]=需重评 + # 静默其他纯信息行(如仅"价XX高出/高于买入区"而无操作建议) + if any("[PUSH]" in i or "[STRATEGY_STALE]" in i for i in issues): + print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") + found += 1 + + if found == 0: + print("[SILENT] 所有策略正常") + + # ----- 组合级警报 ----- + portfolio_alerts = 0 + if holding_count > 0: + if weak_ratio > 40: + print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") + portfolio_alerts += 1 + elif weak_ratio > 30: + print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") + portfolio_alerts += 1 + if position_pct > 80 and holding_count > 0: + # 仓位过满提醒 + print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") + portfolio_alerts += 1 + if portfolio_alerts > 0: + found += portfolio_alerts + + return found + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_reassess_pipeline.py b/scripts/verify_reassess_pipeline.py new file mode 100644 index 00000000..0e99fd4d --- /dev/null +++ b/scripts/verify_reassess_pipeline.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""verify_reassess_pipeline.py — 重评推送管道审计 + 全局cron失败监控 + +检查: +1. price_monitor 每2分正常跑 +2. zone breach检测正常 +3. holding_strategies有数据 +4. XMPP bridge在线 +5. reassess模块可导入 +6. 【新增】所有关键cron job状态(是否有failed) + +输出:正常时 [SILENT],有异常时推XMPP +""" +import json, os, sys, subprocess, sqlite3 +from pathlib import Path +from datetime import datetime, timedelta +from urllib.request import Request, urlopen + +BASE = Path(__file__).parent.parent +sys.path.insert(0, str(BASE)) +sys.path.insert(0, "/home/hmo/MoFin") + +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" + +def xmpp_push(text): + try: + payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode() + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + +def scan_cron_failures(): + """扫描两个cron jobs.json看是否有failed状态的关键job""" + failures = [] + jobs_files = [ + "/home/hmo/.hermes/cron/jobs.json", + "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json", + ] + for jf in jobs_files: + try: + data = json.load(open(jf)) + for job in data.get("jobs", []): + jid = job.get("id", "?") + name = job.get("name", "") or jid[:12] + status = job.get("last_status", "") + enabled = job.get("enabled", True) + if not enabled: + continue + # 关键job:价格监控、重评、盘前中监控 + key_job = any(kw in name.lower() for kw in [ + "price_monitor", "monitor", "盘前中", "reassess", + "重评", "自选买入", "stale_push", "管道审计", + "宏观风险", "策略时效" + ]) + if not key_job: + continue + if status == "failed": + last_run = job.get("last_run_at", "?") + failures.append(f" ❌ {name} ({jid[:8]}) last_run={last_run}") + except Exception: + pass + return failures + +def check_cron_jobs(): + """另法:直接查cron数据库""" + issues = [] + for db_path in [ + BASE / "cron" / "cron.db", + Path("/home/hmo/.hermes/cron/cron.db"), + ]: + if not db_path.exists(): + continue + try: + c = sqlite3.connect(str(db_path)) + for row in c.execute(""" + SELECT id, name, last_status, last_run_at, enabled + FROM cron_jobs WHERE enabled=1 + ORDER BY last_run_at DESC + """).fetchall(): + jid, name, status, last_run, enabled = row + if status == "failed": + issues.append(f" ❌ {name}({jid[:8]}) last_run={last_run}") + c.close() + except Exception: + pass + return issues + +def run(): + ok = True + alerts = [] + checks = [] + + # 1. price_monitor 最近运行时间 + try: + conn = None + last_err = None + # malformed 可能是 I/O 风暴下的瞬态 WAL 损坏(2026-07-21 事件): + # checkpoint 后自愈。重试一次再告警,避免误报轰炸 + for _attempt in range(2): + try: + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + conn.execute("SELECT 1 FROM live_prices LIMIT 1").fetchone() + break + except Exception as e: + last_err = e + import time as _t + _t.sleep(3) + if conn is None: + raise last_err + lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0] + if lp: + lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp + if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None: + if isinstance(lp, str) and '+' not in lp: + lp_dt = lp_dt.replace(tzinfo=None) + mins_ago = (datetime.now() - lp_dt).total_seconds() / 60 + status = "ok" if mins_ago < 10 else "warn" + if mins_ago > 15: + status = "fail" + ok = False + alerts.append(f"price_monitor {mins_ago:.0f}分未更新") + checks.append({"check":"price_monitor","status":status,"detail":f"最后更新{mins_ago:.0f}分前"}) + else: + checks.append({"check":"price_monitor","status":"warn","detail":"live_prices无数据"}) + except Exception as e: + checks.append({"check":"price_monitor","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"price_monitor异常: {e}") + + # 2. 策略评估活动(reassess_with_context写strategy_evaluations,不是holding_strategies) + try: + today_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations WHERE date(created_at)=date('now')").fetchone()[0] + total_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations").fetchone()[0] + # 也尝试查holding_strategies(如果存在并有数据) + hs_exists = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='holding_strategies'").fetchone()[0] + hs = 0 + if hs_exists: + hs = conn.execute("SELECT COUNT(*) FROM holding_strategies").fetchone()[0] + detail = f"今日{today_se}次评估, 累计{total_se}条" + if hs > 0: + detail += f", holding_strategies{hs}条" + checks.append({"check":"strategy_activity","status":"ok","detail":detail}) + except Exception as e: + checks.append({"check":"strategies","status":"fail","detail":str(e)}) + ok = False + + # 3. XMPP bridge 是否在线(TCP端口检测,不发消息到Dad) + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + result = sock.connect_ex(("127.0.0.1", 5805)) + sock.close() + bridge_ok = (result == 0) + if not bridge_ok: + ok = False + alerts.append("XMPP bridge(5805)端口无响应") + checks.append({"check":"xmpp_bridge","status":"ok" if bridge_ok else "fail","detail":"在线" if bridge_ok else "端口无响应"}) + except Exception as e: + checks.append({"check":"xmpp_bridge","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"XMPP bridge不可达: {e}") + + # 4. reassess模块可导入 + try: + from strategy_lifecycle import reassess_with_context + checks.append({"check":"reassess_module","status":"ok","detail":"可导入"}) + except Exception as e: + checks.append({"check":"reassess_module","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"reassess模块导入失败: {e}") + + # 5. cron job失败检测 + cron_issues = scan_cron_failures() + check_cron_jobs() + if cron_issues: + ok = False + alerts.append(f"{len(cron_issues)}个cron job失败") + for issue in cron_issues[:5]: + alerts.append(issue) + checks.append({"check":"cron_jobs","status":"fail","detail":"; ".join(cron_issues[:3])}) + else: + checks.append({"check":"cron_jobs","status":"ok","detail":"所有关键job正常"}) + + conn.close() + + # 输出 + result = { + "pipeline": "ok" if ok else "degraded", + "checked_at": datetime.now().isoformat(), + "checks": checks, + "alerts": alerts + } + + if ok: + print("[SILENT]") + else: + msg = "🔴 重评管道异常:\n" + "\n".join(alerts) + print(json.dumps(result, ensure_ascii=False, indent=2)) + # 有异常时主动推XMPP(取代静默) + xmpp_push(msg) + print(f"\n已推送XMPP: {len(alerts)}条告警", file=sys.stderr) + +if __name__ == "__main__": + run()