Files
MoFin/archive/20260722-scripts-cleanup/batch_reassess.py
T

555 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""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. 禁止输出 <structured_data> 或任何 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()