Files
MoFin/deploy/profile-scripts/batch_reassess.py
T
hmo a40b97f5ca feat(analysis): systematic daily 12-dim LLM analysis for holdings+watchlist
Gap (reported by user via zhiwei): premarket full review updated technical
params but full_analysis (12-dim LLM matrix) was empty for new holdings
and stale for old ones — batch_reassess existed but was never wired into
the daily pipeline and only covered watchlist.

System fix:
- premarket_full_review.py: new Step 1.5 runs batch_reassess --type
  holding --today every trading day 08:10 (force-refresh today's analysis,
  timeout 3600s, result in summary.json)
- batch_reassess.py:
  - coverage: --type holding|watchlist|all (was watchlist-only)
  - staleness: analysis >20h stale gets refreshed (was: skip if any
    analysis exists = forever stale)
  - --today flag: force re-analyze if not reassessed since 04:00 today
  - cash/total read live from portfolio_summary (was hardcoded 321271/
    952879 from weeks ago)
  - HK stock prefix fix (5-digit codes -> hk, was sending sz00700)
- watchlist_12d_backfill.py: wrapper for hermes cron (no args support)
- cron job '批量补全九维分析-一次性' -> '自选12维分析补全-每日午间'
  (daily 12:30 weekdays, covers 109 watchlist stocks missing analysis)

Verified: 300308 got 1920-char 12-dim analysis written to DB at 08:41,
signal=观望, stop/take-profit updated.
2026-07-20 08:48:08 +08:00

425 lines
16 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
from datetime import datetime
DB = "/home/hmo/MoFin/data/mofin.db"
GATEWAY = "http://127.0.0.1:8643/v1/chat/completions"
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读策略
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 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 ""
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() # 实时从 portfolio_summary 读
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
return f"""你是一个资深A股分析师。请对{data['code']} {data.get('name','')}做一个完整的九维矩阵分析,并输出策略参数。
⚠️ 重要:以下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','?')}
原策略:{(data.get('action','') or '')[:200]}
我的总资产={total}元,可用现金={cash}元。
请严格按以下格式输出:
【交叉分析】用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%(理由:一句话说明为什么这个仓位)"""
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"""
conn = sqlite3.connect(DB)
now = datetime.now().isoformat()
updates = ["full_analysis=?", "reassessed_at=?"]
params = [full_text, now]
if parsed["signal"]:
updates.append("timing_signal=?")
params.append(parsed["signal"])
if parsed["entry_low"] > 0:
updates.append("entry_low=?")
params.append(parsed["entry_low"])
if parsed["entry_high"] > 0:
updates.append("entry_high=?")
params.append(parsed["entry_high"])
if parsed["stop_loss"] > 0:
updates.append("stop_loss=?")
params.append(parsed["stop_loss"])
if parsed["take_profit"] > 0:
updates.append("take_profit=?")
params.append(parsed["take_profit"])
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()
# 买入信号→推XMPP通知(在conn close前执行)
if parsed.get("signal") == "买入":
try:
_nr = conn.execute("SELECT name, price FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone()
_name = _nr[0] if _nr else code
_p = _nr[1] if _nr 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", "")
_msg = f"📈 {_name}({code}) 价{_p}→12维分析生成买入信号!区间{_el}~{_eh}{_sl}{_tp} 仓位{_pos}"
import urllib.request, json as _jj
_req = urllib.request.Request("http://127.0.0.1:5805/",
data=_jj.dumps({"body": _msg, "to": "hmo@yoin.fun", "type": "chat"}).encode(),
headers={"Content-Type": "application/json"})
urllib.request.urlopen(_req, timeout=5)
print(f" 📨 XMPP推送成功: {_msg[:60]}")
except Exception as _e:
print(f" ⚠️ XMPP推送失败: {_e}")
conn.close()
def process_stock(code, force_today=False):
"""处理单只股票"""
print(f"\n{'='*50}")
print(f"处理: {code}")
print(f"{'='*50}")
if in_cooldown(code):
print(f" ⏭ 冷却期内,跳过")
return False
# 有分析且未过期 → 跳过(除非 force_today 且今早未评)
if has_llm_analysis(code) and not analysis_stale(code, force_today):
print(f" ⏭ 已有12维分析且未过期,跳过")
return False
print(f" 收集数据...", flush=True)
data = collect_data(code)
if not data.get("price"):
print(f" ⚠️ 无价格数据,跳过")
return False
print(f" 调LLM生成九维分析...", flush=True)
prompt = build_prompt(data)
try:
r = subprocess.run(["curl", "-s", "--max-time", "300",
"-H", "Content-Type: application/json",
"-H", "Authorization: Bearer hermes123",
"-d", json.dumps({"model":"deepseek-v4-flash","messages":[{"role":"user","content":prompt}],"max_tokens":2048}),
GATEWAY], capture_output=True, timeout=310)
if r.returncode != 0:
print(f" ❌ curl失败: {r.stderr.decode()[:100]}")
return False
resp = json.loads(r.stdout)
if "choices" not in resp:
print(f" ❌ API异常: {str(resp)[:200]}")
return False
full_text = resp["choices"][0]["message"]["content"]
print(f" ✅ LLM返回({len(full_text)}字)", flush=True)
parsed = parse_response(full_text)
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" ✅ 已保存到DB")
return True
except subprocess.TimeoutExpired:
print(f" ❌ 超时")
return False
except Exception as e:
print(f" ❌ 错误: {e}")
return False
def main():
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
for i, code in enumerate(codes):
if has_llm_analysis(code) and not analysis_stale(code, force_today):
print(f" [{i+1}/{len(codes)}] ⏭ {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
# 间隔15秒(防gateway过载)
if i < len(codes) - 1:
print(f" 等待15秒...", flush=True)
time.sleep(15)
print(f"\n{'='*50}")
print(f"完成: {ok}成功, {fail}失败, {skip}跳过")
print(f"{'='*50}")
if __name__ == "__main__":
main()