chore: 影子副本大清剿——删除7个陈旧/重复tracked副本
彻查发现8个影子副本(内容漂移): - scripts/batch_reassess.py(07-22旧版,无锚定prompt) - scripts/per_stock_reassess.py(07-22旧版,含已删除的160行独立prompt) - scripts/verify_reassess_pipeline.py(07-21旧版) - scripts/server.py(07-08远古版) - price_monitor.py根+scripts(旧版,无进区摘要关停; 且根副本有未提交改动) - capital_flow_collector.py根+web-dashboard(死符号链接) - hermes scripts下server.py/xiaoguo_news_processor/xiaoguo_scanner(孤儿) 保留: 每类只留唯一canonical(deploy/profile-scripts或根目录SSOT)
This commit is contained in:
@@ -1,554 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,822 +0,0 @@
|
||||
#!/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. 禁止输出 <structured_data> 或任何 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()
|
||||
@@ -1,781 +0,0 @@
|
||||
#!/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<structured_data>{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}</structured_data>")
|
||||
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()
|
||||
@@ -1,827 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""price_monitor.py — 高频价格监控脚本(批量版)
|
||||
规则:进入区间报一次,离开区间报一次,中间不重复。
|
||||
每次运行时一次性刷新所有持仓+自选股的实时价。
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# ── MoFin unified model ──────────────────────────────────────────────
|
||||
sys.path.insert(0, "/home/hmo/MoFin")
|
||||
from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct
|
||||
from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock
|
||||
from mo_data import read_portfolio, read_decisions, read_watchlist
|
||||
|
||||
DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json"
|
||||
PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json"
|
||||
WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json"
|
||||
BREACH_PATH = "/home/hmo/.hermes/zone_breach.json"
|
||||
STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json")
|
||||
EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json"
|
||||
|
||||
# 策略重评依赖(技术面驱动,非机械百分比)
|
||||
sys.path.insert(0, "/home/hmo/web-dashboard")
|
||||
try:
|
||||
from strategy_lifecycle import reassess_strategy
|
||||
HAS_REASSESS = True
|
||||
except ImportError:
|
||||
HAS_REASSESS = False
|
||||
|
||||
try:
|
||||
HK_RATE = get_hk_rate()
|
||||
except Exception:
|
||||
HK_RATE = 0.87 # ultimate fallback
|
||||
|
||||
# 分支系统与情景检测
|
||||
try:
|
||||
sys.path.insert(0, '/home/hmo/MoFin')
|
||||
from strategy_tree import detect_scenario, evaluate_branches
|
||||
HAS_TREE = True
|
||||
except Exception:
|
||||
HAS_TREE = False
|
||||
def detect_scenario(): return {}
|
||||
def evaluate_branches(*a, **kw): return []
|
||||
|
||||
# 情景缓存(每次run_once刷新)
|
||||
_SCENARIO_CACHE = {}
|
||||
_BRANCH_CACHE = {} # code -> branches list
|
||||
|
||||
UA = "Mozilla/5.0"
|
||||
|
||||
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_all_prices(codes):
|
||||
"""腾讯批量行情API:仅用于A股(沪市/深市)
|
||||
A股:sh600110 / sz000001
|
||||
港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情)
|
||||
返回 {code: (price, change, change_pct)}
|
||||
"""
|
||||
if not codes:
|
||||
return {}
|
||||
|
||||
# 只处理A股(6位代码),港股走东方财富
|
||||
a_codes = [c for c in codes if len(str(c).strip()) == 6]
|
||||
if not a_codes:
|
||||
return {}
|
||||
|
||||
symbols = []
|
||||
code_map = {}
|
||||
for code in a_codes:
|
||||
code_s = str(code).strip()
|
||||
if code_s.startswith(('5', '6', '9')):
|
||||
sym = f"sh{code_s}"
|
||||
else:
|
||||
sym = f"sz{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"⚠️ 腾讯A股拉取失败: {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:
|
||||
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 fetch_hk_sina_batch(codes):
|
||||
"""新浪财经港股批量实时行情 — 一次HTTP请求获取全部港股。
|
||||
|
||||
新浪港股API(hq.sinajs.cn)支持批量查询,返回实时数据。
|
||||
对比东财逐股查询(0.2s间隔×17只=3.4s),新浪1次请求搞定。
|
||||
|
||||
API: https://hq.sinajs.cn/list=hk00700,hk09988
|
||||
格式: hq_str_hk00700="TENCENT,腾讯控股,当前价,昨收,开盘,最高,最低,涨跌额,涨跌幅,..."
|
||||
|
||||
返回 {code: (price, change, change_pct)}
|
||||
"""
|
||||
if not codes:
|
||||
return {}
|
||||
|
||||
hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5]
|
||||
if not hk_codes:
|
||||
return {}
|
||||
|
||||
symbols = [f"hk{c}" for c in hk_codes]
|
||||
url = f"https://hq.sinajs.cn/list={','.join(symbols)}"
|
||||
|
||||
try:
|
||||
# 新浪要求有 Referer,且需绕过系统代理(某些环境下东财/新浪走代理会断连)
|
||||
proxy_handler = urllib.request.ProxyHandler({})
|
||||
opener = urllib.request.build_opener(proxy_handler)
|
||||
req = urllib.request.Request(url, headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Referer": "https://finance.sina.com.cn",
|
||||
})
|
||||
with opener.open(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 in line:
|
||||
continue
|
||||
try:
|
||||
code = line.split("=", 1)[0].replace("hq_str_hk", "").replace("var ", "").strip()
|
||||
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
|
||||
fields = raw.split(",")
|
||||
if len(fields) < 9:
|
||||
continue
|
||||
price = float(fields[2]) if fields[2] else 0
|
||||
prev_close = float(fields[3]) if fields[3] else 0
|
||||
change_amt = float(fields[7]) if fields[7] else 0
|
||||
change_pct = fields[8] if fields[8] else "0"
|
||||
# 新浪 field[2] 可能非实时最新价,用 prev_close + change 计算更准确
|
||||
if prev_close > 0 and abs(change_amt) > 0:
|
||||
price = round(prev_close + change_amt, 2)
|
||||
change = round(change_amt, 2)
|
||||
if price > 0:
|
||||
results[code] = (price, change, change_pct)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── 港股备用通道(东方财富逐股 + 腾讯15min延迟)───────────────────────────
|
||||
|
||||
def fetch_hk_eastmoney_fallback(codes):
|
||||
"""东方财富港股实时行情(备用通道),逐股查询、间隔1秒避免限流。
|
||||
|
||||
FTP 说明:港股限流严重,不适合主通道,降级为备用。
|
||||
建议用上面的 fetch_hk_sina_batch() 做主通道。
|
||||
|
||||
返回 {code: (price, change, change_pct)}
|
||||
Fallback: 仍失败时回退到腾讯 qt.gtimg.cn(15分钟延迟)
|
||||
"""
|
||||
if not codes:
|
||||
return {}
|
||||
|
||||
hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5]
|
||||
if not hk_codes:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
|
||||
# 东方财富逐股查询,1秒间隔避免限流
|
||||
for code in hk_codes:
|
||||
try:
|
||||
url = (f"https://push2.eastmoney.com/api/qt/stock/get"
|
||||
f"?secid=116.{code}"
|
||||
f"&fields=f43,f170,f60,f57,f58"
|
||||
f"&fltt=2")
|
||||
proxy_handler = urllib.request.ProxyHandler({})
|
||||
opener = urllib.request.build_opener(proxy_handler)
|
||||
req = urllib.request.Request(url, headers={
|
||||
"User-Agent": UA,
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
})
|
||||
with opener.open(req, timeout=5) as r:
|
||||
resp = json.loads(r.read().decode("utf-8"))
|
||||
|
||||
if resp.get("rc") != 0:
|
||||
continue
|
||||
item = resp.get("data", {})
|
||||
if not item:
|
||||
continue
|
||||
price = float(item.get("f43", 0)) if item.get("f43") else 0
|
||||
prev_close = float(item.get("f60", 0)) if item.get("f60") else 0
|
||||
change = round(price - prev_close, 2) if prev_close > 0 else 0
|
||||
change_pct = str(item.get("f170", "0"))
|
||||
if price > 0:
|
||||
results[code] = (price, change, change_pct)
|
||||
time.sleep(1.0) # 1秒间隔,大幅降低限流概率
|
||||
except Exception as e:
|
||||
print(f" [东财备用 {code}] {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Fallback: 腾讯 qt.gtimg.cn(15分钟延迟)
|
||||
missing = [c for c in hk_codes if c not in results]
|
||||
if missing:
|
||||
try:
|
||||
fallback = _fetch_hk_tencent_fallback(missing)
|
||||
results.update(fallback)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _fetch_hk_tencent_fallback(codes):
|
||||
"""腾讯港股行情(15分钟延迟,仅作 fallback)"""
|
||||
symbols = [f"hk{c}" for c in codes]
|
||||
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
text = r.read().decode("gbk")
|
||||
|
||||
code_map = {f"hk{c}": c for c in codes}
|
||||
results = {}
|
||||
for line in text.strip().split("\n"):
|
||||
if "=" not in line:
|
||||
continue
|
||||
try:
|
||||
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
|
||||
fields = raw.split("~")
|
||||
if len(fields) < 6:
|
||||
continue
|
||||
sym = line.split("=", 1)[0].strip().lstrip("v_")
|
||||
orig = code_map.get(sym)
|
||||
if not orig:
|
||||
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] = (price, change, change_pct)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def refresh_data_prices():
|
||||
"""一次性刷新portfolio.json和watchlist.json的所有实时价"""
|
||||
all_codes = set()
|
||||
|
||||
# 收集所有需要拉取的代码
|
||||
try:
|
||||
pf = read_portfolio()
|
||||
for s in pf.get('holdings', []):
|
||||
all_codes.add(s['code'])
|
||||
except Exception:
|
||||
pf = {"holdings": []}
|
||||
|
||||
try:
|
||||
wl = read_watchlist()
|
||||
for s in wl.get('stocks', []):
|
||||
all_codes.add(s['code'])
|
||||
except Exception:
|
||||
wl = {"stocks": []}
|
||||
|
||||
if not all_codes:
|
||||
return 0
|
||||
|
||||
# 分批拉取:A股走腾讯(实时) + 港股走新浪批量(实时,无限流)
|
||||
all_list = list(all_codes)
|
||||
prices = fetch_all_prices(all_list) # A股(腾讯,实时)
|
||||
hk_prices = fetch_hk_sina_batch(all_list) # 港股(新浪批量,实时)
|
||||
# 新浪未覆盖的走备用通道(东财逐股→腾讯15min延迟)
|
||||
# 港股市场09:30开盘,之前走备用通道会空耗1秒/只且无实时数据
|
||||
hk_codes_missing = [c for c in all_list if len(str(c).strip()) <= 5 and c not in hk_prices]
|
||||
if hk_codes_missing:
|
||||
# 09:30前港股未开盘,跳过慢速降级通道
|
||||
now_h = datetime.now().hour
|
||||
now_m = datetime.now().minute
|
||||
if now_h > 9 or (now_h == 9 and now_m >= 30):
|
||||
fallback = fetch_hk_eastmoney_fallback(hk_codes_missing)
|
||||
hk_prices.update(fallback)
|
||||
prices.update(hk_prices)
|
||||
updated = 0
|
||||
|
||||
# 保存全量实时价快照(供报告管道消费,确保分析用最新数据)
|
||||
try:
|
||||
live = {"updated_at": datetime.now().isoformat(), "prices": {}}
|
||||
for code in all_codes:
|
||||
if code in prices:
|
||||
p, c, chg = prices[code]
|
||||
live["prices"][code] = {"price": p, "change_pct": chg}
|
||||
json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 更新portfolio(只在价格变化时写入,避免触发文件变更通知)
|
||||
changed = False
|
||||
for s in pf.get('holdings', []):
|
||||
if s['code'] in prices:
|
||||
price, _, change_pct = prices[s['code']]
|
||||
if price > 0:
|
||||
# 港股:API返回HKD,需转RMB
|
||||
if is_hk_stock(s['code']):
|
||||
price = round(price * HK_RATE, 2)
|
||||
old = s.get('price')
|
||||
if old is None:
|
||||
old = 0
|
||||
if abs(old - price) > 0.001:
|
||||
s['price'] = round(price, 2)
|
||||
s['change_pct'] = float(change_pct) if change_pct else 0
|
||||
updated += 1
|
||||
changed = True
|
||||
if changed:
|
||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
pf['total_mv'] = calc_total_mv(pf.get('holdings', []))
|
||||
pf['total_assets'] = calc_total_assets(pf)
|
||||
pf['position_pct'] = calc_position_pct(pf)
|
||||
# DB 写入(替代 json.dump,强制币种约束)
|
||||
try:
|
||||
conn = get_conn()
|
||||
write_holdings_batch(conn, pf['holdings'])
|
||||
write_portfolio_summary(conn, pf)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f" [DB写入失败] {e}", flush=True)
|
||||
# 保留 JSON 副本作为冷备
|
||||
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
elif pf.get('updated_at'):
|
||||
try:
|
||||
last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M')
|
||||
if (datetime.now() - last_ts).total_seconds() > 600:
|
||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 更新watchlist(只在价格变化时写入)
|
||||
changed = False
|
||||
for s in wl.get('stocks', []):
|
||||
if s['code'] in prices:
|
||||
price, _, change_pct = prices[s['code']]
|
||||
if price > 0:
|
||||
# 港股:API返回HKD,需转RMB
|
||||
if is_hk_stock(s['code']):
|
||||
price = round(price * HK_RATE, 2)
|
||||
old = s.get('price')
|
||||
if old is None:
|
||||
old = 0
|
||||
if abs(old - price) > 0.001:
|
||||
s['price'] = round(price, 2)
|
||||
s['change_pct'] = float(change_pct) if change_pct else 0
|
||||
updated += 1
|
||||
changed = True
|
||||
if changed:
|
||||
wl['updated_at'] = datetime.now().isoformat()
|
||||
# DB 写入(替代 json.dump)
|
||||
try:
|
||||
conn = get_conn()
|
||||
for s in wl.get('stocks', []):
|
||||
s['currency'] = 'CNY' # 自选股价格统一CNY
|
||||
write_watchlist_stock(conn, s)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f" [DB watchlist写入失败] {e}", flush=True)
|
||||
# 保留 JSON 冷备
|
||||
json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
|
||||
# --- 汇总值重算(使用 mo_models 唯一公式)---
|
||||
try:
|
||||
live_market_value = calc_total_mv(pf.get('holdings', []))
|
||||
old_mv = pf.get('total_mv', 0)
|
||||
|
||||
if abs(old_mv - live_market_value) > 0.01:
|
||||
pf['total_mv'] = round(live_market_value, 2)
|
||||
|
||||
pf['total_assets'] = calc_total_assets(pf)
|
||||
if pf['total_assets'] > 0:
|
||||
pf['position_pct'] = calc_position_pct(pf)
|
||||
pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
# DB 写入
|
||||
try:
|
||||
conn = get_conn()
|
||||
write_portfolio_summary(conn, pf)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f" [DB汇总写入失败] {e}", flush=True)
|
||||
# JSON 冷备
|
||||
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f" [汇总重算失败] {e}", flush=True)
|
||||
# --- 结束汇总重算 ---
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
# ── 分支系统辅助函数 ──────────────────────────────────────────────────────
|
||||
|
||||
def _branch_alert_suffix(code, price, shares=0, cost=0):
|
||||
"""返回分支信息后缀:「 | 情景→动作」"""
|
||||
if not HAS_TREE or not _SCENARIO_CACHE.get('id'):
|
||||
return ""
|
||||
try:
|
||||
sc_id = _SCENARIO_CACHE['id']
|
||||
results = evaluate_branches(code, sc_id, price, shares, cost)
|
||||
for r in results:
|
||||
if r.get('applicable'):
|
||||
_record_branch_trigger(code, r.get('branch_id',''), price)
|
||||
branch_action = r.get('action_type', r.get('action', 'hold'))
|
||||
return f" | {sc_id}→{branch_action}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _record_branch_trigger(code, branch_id, price):
|
||||
"""记录分支触发事件(自成长:trigger_count+1)"""
|
||||
try:
|
||||
raw = read_decisions()
|
||||
for d in raw.get('decisions', []):
|
||||
if d.get('code') == code and d.get('strategy_tree',{}).get('branches'):
|
||||
for b in d['strategy_tree']['branches']:
|
||||
if b['id'] == branch_id:
|
||||
b.setdefault('trigger_count', 0)
|
||||
b['trigger_count'] += 1
|
||||
b['last_trigger_price'] = round(price, 2)
|
||||
b['last_triggered'] = datetime.now().isoformat()
|
||||
break
|
||||
json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 区间偏离检测 ──────────────────────────────────────────────────────────
|
||||
|
||||
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 load_events():
|
||||
try:
|
||||
with open(EVENTS_PATH) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {"events": []}
|
||||
|
||||
|
||||
def save_events(events):
|
||||
os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True)
|
||||
with open(EVENTS_PATH, 'w') as f:
|
||||
json.dump(events, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def record_event(code, name, event_type, price, trigger_value, event_label=""):
|
||||
"""记录一次价格触发事件到 price_events.json + SQLite"""
|
||||
events = load_events()
|
||||
now = datetime.now().isoformat()
|
||||
events["events"].append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone
|
||||
"price": round(price, 2),
|
||||
"trigger_value": trigger_value,
|
||||
"event_label": event_label,
|
||||
"timestamp": now,
|
||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||
})
|
||||
# 保留最近10000条
|
||||
events["events"] = events["events"][-10000:]
|
||||
save_events(events)
|
||||
|
||||
# ── SQLite 双写 ──
|
||||
try:
|
||||
from mofin_db import get_conn, init_all_tables, write_price_event
|
||||
conn = get_conn()
|
||||
init_all_tables(conn)
|
||||
write_price_event(conn, code, name, event_type, price, trigger_value, event_label)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass # SQLite 写入失败不影响主流程
|
||||
|
||||
|
||||
def get_trigger_zones(d):
|
||||
"""返回该decision所有可监控的区间列表,从顶层字段读取"""
|
||||
zones = []
|
||||
is_holding = d.get('shares', 0) > 0
|
||||
# 买入区间(自选和持仓都监控)
|
||||
el = d.get("entry_low", 0)
|
||||
eh = d.get("entry_high", 0)
|
||||
if el and eh and float(el) > 0 and float(eh) > 0:
|
||||
try:
|
||||
zones.append(("entry_zone", "买入区间", float(el), float(eh)))
|
||||
except:
|
||||
pass
|
||||
# 止损+止盈(只有持仓才监控,自选无意义)
|
||||
if is_holding:
|
||||
sl = d.get("stop_loss", 0)
|
||||
if sl and float(sl) > 0:
|
||||
try:
|
||||
zones.append(("stop_loss", "止损", 0, float(sl)))
|
||||
except:
|
||||
pass
|
||||
tp = d.get("take_profit", 0)
|
||||
if tp and float(tp) > 0:
|
||||
try:
|
||||
zones.append(("take_profit_zone", "止盈区间", 0, float(tp)))
|
||||
except:
|
||||
pass
|
||||
return zones
|
||||
|
||||
|
||||
def run_once(round_label=""):
|
||||
"""执行一轮完整的监控流程"""
|
||||
global _SCENARIO_CACHE, _BRANCH_CACHE
|
||||
label = f" [{round_label}]" if round_label else ""
|
||||
start = time.time()
|
||||
|
||||
# 刷新情景与分支缓存(每轮更新)
|
||||
_SCENARIO_CACHE = detect_scenario() if HAS_TREE else {}
|
||||
_BRANCH_CACHE = {}
|
||||
try:
|
||||
raw = read_decisions()
|
||||
for d in raw.get('decisions', []):
|
||||
tree = d.get('strategy_tree', {})
|
||||
if tree and tree.get('branches'):
|
||||
_BRANCH_CACHE[d['code']] = tree['branches']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === 第一步:一次性刷新所有价格 ===
|
||||
refreshed = refresh_data_prices()
|
||||
|
||||
# === 第二步:检查触发条件 ===
|
||||
try:
|
||||
with open(DECISIONS_PATH) as f:
|
||||
dec = json.load(f)
|
||||
except:
|
||||
print(f"❌{label} 无法读取decisions.json", file=sys.stderr)
|
||||
return
|
||||
|
||||
active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")]
|
||||
state = load_state()
|
||||
outputs = []
|
||||
state_updated = False
|
||||
reassesed_codes = [] # 止损触发和离/进买入区都记入此列表
|
||||
|
||||
# 收集所有需要检查的代码
|
||||
check_codes = set()
|
||||
for d in active:
|
||||
if get_trigger_zones(d):
|
||||
check_codes.add(d["code"])
|
||||
|
||||
# 批量拉取这些股票的价格
|
||||
prices = fetch_all_prices(list(check_codes))
|
||||
|
||||
for d in active:
|
||||
code = d["code"]
|
||||
|
||||
zones = get_trigger_zones(d)
|
||||
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] = {}
|
||||
|
||||
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":
|
||||
branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0))
|
||||
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}")
|
||||
record_event(code, name, "stop_loss", price, str(hi))
|
||||
# --- 止损触发 → 立即重评 + 操作建议 ---
|
||||
if 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
|
||||
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,
|
||||
)
|
||||
# 生成操作建议
|
||||
new_sl = result.get('stop_loss', 0)
|
||||
if price < hi: # 跌破止损 → 建议卖出
|
||||
advice = "建议止损卖出"
|
||||
elif new_sl > 0 and price > new_sl:
|
||||
advice = f"建议观察, 设新止损{new_sl:.2f}"
|
||||
else:
|
||||
advice = "建议持有观察"
|
||||
outputs.append(f" 📊 {advice} | 新损{result['stop_loss']} 盈{result['take_profit']} RR={result['rr_ratio']}")
|
||||
reassesed_codes.append(code)
|
||||
except Exception as e:
|
||||
outputs.append(f" ⚠️ 止损重评失败: {e}")
|
||||
else:
|
||||
extra = ""
|
||||
if "_price" in key:
|
||||
batch_shares = d.get(key.replace("_price", "_shares"), "")
|
||||
action = d.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 = d.get("take_profit_action", "")
|
||||
if act:
|
||||
extra = f"({act})"
|
||||
branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0))
|
||||
outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}")
|
||||
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
|
||||
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
|
||||
|
||||
# === 第三步:买入区偏离检测 + 自动重评 ===
|
||||
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.json 中读取 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)
|
||||
|
||||
# 状态变化时才触发:True→False离区 或 False→True进区
|
||||
# [2026-07-01 fix] prev_in_buy_zone is None(新加自选首次检测)
|
||||
# 也要触发——否则新自选全程不走重评,timing_signal卡在初始值
|
||||
if in_buy_zone and (prev_in_buy_zone == False or prev_in_buy_zone is None):
|
||||
# 进入买入区 → 触发技术面重评,更新止损/止盈/信号
|
||||
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
|
||||
|
||||
# 如果有重评过的股票,更新 decisions.json
|
||||
if reassesed_codes and HAS_REASSESS:
|
||||
try:
|
||||
# 重新 regenerate_all 只针对受影响的股票效率太低
|
||||
# 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析)
|
||||
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)}")
|
||||
except Exception as e:
|
||||
outputs.append(f" ⚠️ 全量重评失败: {e}")
|
||||
|
||||
# === 3.5 资金流异常检测(2026-06-27 新增)===
|
||||
try:
|
||||
cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json"))
|
||||
# 检查所有 active decision 中的资金流异常
|
||||
for d in active:
|
||||
code = d["code"]
|
||||
stock_cf = cf.get("stocks", {}).get(code, {})
|
||||
analysis = stock_cf.get("analysis", {})
|
||||
alerts = analysis.get("alerts", [])
|
||||
if alerts:
|
||||
name = d.get("name", code)
|
||||
for a in alerts:
|
||||
outputs.append(f" 💰 {name}({code}) {a}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === 第四步:情景变化检测 + 输出 → 直接推XMPP ===
|
||||
now_str = datetime.now().strftime("%H:%M:%S")
|
||||
elapsed = time.time() - start
|
||||
|
||||
# 情景变化检测(跨轮对比)
|
||||
if HAS_TREE and _SCENARIO_CACHE.get('id'):
|
||||
prev_scenario = state.get('_system', {}).get('last_scenario', '')
|
||||
curr_scenario = _SCENARIO_CACHE['id']
|
||||
if prev_scenario and curr_scenario != prev_scenario:
|
||||
combo = _SCENARIO_CACHE.get('combo_action', '')
|
||||
outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}")
|
||||
if outputs:
|
||||
state.setdefault('_system', {})['last_scenario'] = curr_scenario
|
||||
state_updated = True
|
||||
elif not prev_scenario:
|
||||
state.setdefault('_system', {})['last_scenario'] = curr_scenario
|
||||
state_updated = True
|
||||
|
||||
if outputs:
|
||||
# 简短一行一个触发
|
||||
for o in outputs:
|
||||
print(o)
|
||||
# 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节)
|
||||
critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))]
|
||||
if critical:
|
||||
try:
|
||||
body = "\n".join([f"{now_str}"] + critical)
|
||||
payload = json.dumps({
|
||||
"to": "hmo@yoin.fun", "body": body, "type": "chat",
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:5805/", data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
# else: SILENT — 无触发,无输出,不推
|
||||
|
||||
if state_updated:
|
||||
save_state(state)
|
||||
|
||||
|
||||
def main():
|
||||
"""每cron触发跑一轮"""
|
||||
run_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-1071
File diff suppressed because it is too large
Load Diff
@@ -1,206 +0,0 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user