#!/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()