Initial: MoFin 持仓分析与策略管理系统
核心模块: - 策略生命周期管理 (strategy_lifecycle.py) - 技术分析引擎 (technical_analysis.py) - 双维度策略评估 (strategy_evaluator.py) - 实时行情获取 (get_realtime_prices.py) - Web Dashboard (server.py, :8899) 提示词版本管理: - prompt_manager 模块 — 统一管理所有知微提示词 - 8个提示词共24个版本已录入 - 策略→提示词版本关联追踪 - Dashboard「提示词」Tab 数据源增强: - 服务端 POST /api/update/realtime 端点已就绪 - clients/tdx-relay/ — 小小莫在Windows上开发的通达信中继 - 解决港股15分钟延迟问题
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cron_to_xmpp.py — 智能cron报告推送
|
||||
|
||||
只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。
|
||||
关键规则:
|
||||
1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据)
|
||||
2. 跳过自己的输出目录(30908cdc44a8),避免循环推送
|
||||
3. 正文太短(<20字)或只有 [SILENT] 的不推
|
||||
4. 超时自动跳过,不影响后续
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 使用绝对路径,不受 profile 环境变量影响
|
||||
REAL_HOME = Path("/home/hmo")
|
||||
|
||||
# 扫描目录
|
||||
CRON_DIRS = [
|
||||
REAL_HOME / ".hermes" / "cron" / "output",
|
||||
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output",
|
||||
]
|
||||
JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json"
|
||||
SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json"
|
||||
MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史
|
||||
|
||||
|
||||
def load_no_agent_job_ids():
|
||||
"""从两个profile的jobs.json中读取所有no_agent=true的job ID"""
|
||||
ids = set()
|
||||
for jobs_path in [
|
||||
REAL_HOME / ".hermes" / "cron" / "jobs.json",
|
||||
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json",
|
||||
]:
|
||||
try:
|
||||
with open(jobs_path) as f:
|
||||
data = json.load(f)
|
||||
for j in data.get("jobs", []):
|
||||
if j.get("no_agent"):
|
||||
ids.add(j["id"])
|
||||
except:
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
# 硬编码保底(如果 jobs.json 读不到)
|
||||
SKIP_DIRS = {
|
||||
"30908cdc44a8", # cron-推XMPP中继自身输出
|
||||
"health", # 健康检查输出
|
||||
}
|
||||
|
||||
FROM = "zhiwei@yoin.fun"
|
||||
TO = "hmo@yoin.fun"
|
||||
|
||||
|
||||
def load_journal():
|
||||
try:
|
||||
return set(json.loads(JOURNAL.read_text()))
|
||||
except:
|
||||
return set()
|
||||
|
||||
|
||||
def save_journal(entries):
|
||||
JOURNAL.write_text(json.dumps(sorted(entries)))
|
||||
|
||||
|
||||
def is_pure_script_output(content):
|
||||
"""判断文件是否是纯脚本的机器输出(不是LLM报告)"""
|
||||
# LLM报告的特征:有 ## Response 节(包含agent的回复)
|
||||
if "## Response" in content:
|
||||
return False
|
||||
# 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出
|
||||
if content.startswith("# Cron Job:"):
|
||||
return True
|
||||
# 价格监控的触发输出
|
||||
if content.startswith("🔔") and "⏱" in content:
|
||||
return True
|
||||
# 健康检查报告
|
||||
if "MoFin 系统健康检查" in content:
|
||||
return True
|
||||
# [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默
|
||||
# 移除 [SILENT] 过滤,让报告始终送达
|
||||
# 结构化数据标签(价格监控的机器数据)
|
||||
if "<structured_data>" in content:
|
||||
return True
|
||||
# no_agent 脚本的输出特征(Hermes自动添加的header)
|
||||
if "**Mode:** no_agent (script)" in content:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_report_body(body):
|
||||
"""质量检查 — 不拦截,返回改进建议"""
|
||||
issues = []
|
||||
text = body.strip()
|
||||
|
||||
if "重点推荐操作" not in text:
|
||||
issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)")
|
||||
|
||||
if "风险关注" not in text:
|
||||
issues.append("缺少【风险关注】区域(如无风险可写「无」)")
|
||||
|
||||
if len(text) > 600:
|
||||
issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内")
|
||||
|
||||
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
|
||||
if fuzzy:
|
||||
issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令")
|
||||
|
||||
if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text):
|
||||
issues.append("含选择题句式,建议只给一个确定建议")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def send_feedback(issues, job_name):
|
||||
"""发送质量反馈给知微自己"""
|
||||
from xml.sax.saxutils import escape
|
||||
feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues)
|
||||
safe = escape(feedback)
|
||||
stanza = (
|
||||
f"<message from='{FROM}' to='{FROM}' "
|
||||
f"type='chat' xml:lang='en'>"
|
||||
f"<body>{safe}</body></message>"
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl",
|
||||
"send_stanza", FROM, FROM, stanza],
|
||||
capture_output=True, timeout=10, text=True,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def extract_body(path):
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if is_pure_script_output(content):
|
||||
return None
|
||||
|
||||
parts = content.split("## Response")
|
||||
body = parts[1].strip() if len(parts) > 1 else content.strip()
|
||||
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
|
||||
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
|
||||
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
|
||||
|
||||
# 去掉agent的思考过程("Now let me...", "Let me...", "Now I have..."等开头)
|
||||
body = re.sub(r'^(Now let me|Let me|I need|I will|First let me|First,? I|Now I have|Here.i|I.ll|I.m ).*?\n\n', '', body, flags=re.DOTALL).strip()
|
||||
# 去掉末尾的思考尾巴
|
||||
body = re.sub(r'\n\s*(Now I|This |I have |I used |The report|The data).*?$', '', body, flags=re.DOTALL).strip()
|
||||
# 如果只剩"好的"、"收到"等短回应,丢弃
|
||||
if re.match(r'^[\u4e00-\u9fff,。]{1,10}$', body):
|
||||
return None
|
||||
|
||||
if not body or len(body) < 20:
|
||||
return None
|
||||
|
||||
# 再次检查正文中是否包含 [SILENT]
|
||||
if "[SILENT]" in body:
|
||||
return None
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def send(body):
|
||||
from xml.sax.saxutils import escape
|
||||
safe = escape(f"【知微】{body}")
|
||||
stanza = (
|
||||
f"<message from='{FROM}' to='{TO}' "
|
||||
f"type='chat' xml:lang='en'>"
|
||||
f"<body>{safe}</body></message>"
|
||||
)
|
||||
# 重试3次
|
||||
for attempt in range(3):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl",
|
||||
"send_stanza", FROM, TO, stanza],
|
||||
capture_output=True, timeout=10, text=True,
|
||||
)
|
||||
if r.stderr and "error" in r.stderr.lower():
|
||||
print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
return r.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"send timeout (attempt {attempt+1})", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def validate_format(body):
|
||||
"""格式检查 — 只记录不拦截,标记改进点"""
|
||||
text = body.strip()
|
||||
issues = []
|
||||
|
||||
# 必含区域检查
|
||||
has_key = "重点推荐操作" in text
|
||||
has_risk = "风险关注" in text
|
||||
has_rest = "其余持仓" in text or "今日关注" in text
|
||||
if not has_key:
|
||||
issues.append("缺【重点推荐操作】区域")
|
||||
if not has_risk:
|
||||
issues.append("缺【风险关注】区域")
|
||||
|
||||
# 超长提醒
|
||||
if len(text) > 600:
|
||||
issues.append(f"报告偏长({len(text)}字),建议压缩到600字内")
|
||||
|
||||
# 模糊词提醒
|
||||
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
|
||||
if fuzzy:
|
||||
issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论")
|
||||
|
||||
# 选择题句式提醒
|
||||
if re.search(r"如果.*就|若.*则|可以.*也可以", text):
|
||||
issues.append("含选择题句式,应给唯一建议")
|
||||
|
||||
return text, issues # 始终通过,issues 为空就是干净
|
||||
|
||||
|
||||
def load_silent_stats():
|
||||
"""加载当日静默统计"""
|
||||
try:
|
||||
return json.loads(SILENT_STATS.read_text())
|
||||
except:
|
||||
return {"date": "", "silent": 0, "short": 0, "script": 0}
|
||||
|
||||
|
||||
def save_silent_stats(stats):
|
||||
SILENT_STATS.write_text(json.dumps(stats))
|
||||
|
||||
|
||||
def send_silent_summary(stats):
|
||||
"""发送当日静默报告汇总"""
|
||||
parts = []
|
||||
if stats.get("silent", 0) > 0:
|
||||
parts.append(f"静默[SILENT] {stats['silent']}次")
|
||||
if stats.get("short", 0) > 0:
|
||||
parts.append(f"过短(<20字) {stats['short']}次")
|
||||
if stats.get("script", 0) > 0:
|
||||
parts.append(f"脚本输出 {stats['script']}次")
|
||||
|
||||
if not parts:
|
||||
body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。"
|
||||
else:
|
||||
body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。"
|
||||
|
||||
send(body)
|
||||
|
||||
|
||||
def scan():
|
||||
processed = load_journal()
|
||||
new = set()
|
||||
n_pushed = 0
|
||||
n_silent = 0
|
||||
n_short = 0
|
||||
n_script = 0
|
||||
no_agent_ids = load_no_agent_job_ids()
|
||||
skip_all = SKIP_DIRS | no_agent_ids
|
||||
|
||||
for cron_dir in CRON_DIRS:
|
||||
if not cron_dir.exists():
|
||||
continue
|
||||
|
||||
for d in sorted(cron_dir.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
if d.name in skip_all:
|
||||
continue
|
||||
|
||||
for f in sorted(d.iterdir()):
|
||||
if f.suffix != ".md":
|
||||
continue
|
||||
key = str(f.resolve())
|
||||
if key in processed or key in new:
|
||||
continue
|
||||
new.add(key)
|
||||
|
||||
# 跳过超过MAX_AGE_HOURS小时的旧文件
|
||||
age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600
|
||||
if age_hours > MAX_AGE_HOURS:
|
||||
continue
|
||||
|
||||
content = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# 提前判断脚本输出
|
||||
if is_pure_script_output(content):
|
||||
n_script += 1
|
||||
continue
|
||||
|
||||
parts = content.split("## Response")
|
||||
body = parts[1].strip() if len(parts) > 1 else content.strip()
|
||||
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
|
||||
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
|
||||
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
|
||||
|
||||
if not body or len(body) < 20:
|
||||
n_short += 1
|
||||
continue
|
||||
|
||||
# SILENT → 拦截,记数
|
||||
if "[SILENT]" in body:
|
||||
n_silent += 1
|
||||
continue
|
||||
|
||||
# 格式校验 — 记录改进点,不拦截
|
||||
ok_body, issues = validate_format(body)
|
||||
|
||||
n_pushed += 1
|
||||
ok_sent = send(body)
|
||||
if not ok_sent:
|
||||
print(f" {d.name}: send failed", file=sys.stderr)
|
||||
if issues:
|
||||
print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr)
|
||||
|
||||
if new:
|
||||
save_journal(processed | new)
|
||||
|
||||
# 保存当日汇总到文件(供16:30汇总用)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
stats = load_silent_stats()
|
||||
if stats.get("date") != today:
|
||||
stats = {"date": today, "silent": 0, "short": 0, "script": 0}
|
||||
stats["silent"] += n_silent
|
||||
stats["short"] += n_short
|
||||
stats["script"] += n_script
|
||||
save_silent_stats(stats)
|
||||
|
||||
# 16:30~16:35 发送当日汇总(收盘后)
|
||||
now = datetime.now()
|
||||
hhmm = now.hour * 60 + now.minute
|
||||
if 990 <= hhmm <= 995: # 16:30~16:35
|
||||
send_silent_summary(stats)
|
||||
|
||||
log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份"
|
||||
print(log, file=sys.stderr)
|
||||
return n_pushed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scan()
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""market_watch.py — 行業熱點 + 市場洞察數據採集,寫入 dashboard data/market.json"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
# ── 後端A:東方財富 push2 API(首選) ──
|
||||
|
||||
def _fetch_em(url):
|
||||
"""通用 EM API 請求"""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def fetch_sector_em():
|
||||
"""東方財富行業板塊"""
|
||||
try:
|
||||
data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=15&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2")
|
||||
return [{"name": i["f14"], "code": i["f12"], "price": i.get("f2", 0), "change": i.get("f3", 0)}
|
||||
for i in data.get("data", {}).get("diff", [])]
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def fetch_concept_em():
|
||||
"""東方財富概念板塊"""
|
||||
try:
|
||||
data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=10&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3")
|
||||
return [{"name": i["f14"], "code": i["f12"], "change": i.get("f3", 0)}
|
||||
for i in data.get("data", {}).get("diff", [])]
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
# ── 後端B:同花順 THS(降級) ──
|
||||
|
||||
def fetch_sector_ths():
|
||||
"""THS 行業板塊(含漲跌家數、資金流向)"""
|
||||
try:
|
||||
import akshare as ak
|
||||
df = ak.stock_board_industry_summary_ths()
|
||||
return [{
|
||||
"name": r["板块"], "code": "", "price": 0,
|
||||
"change": float(r.get("涨跌幅", 0)),
|
||||
"up_count": int(r.get("上涨家数", 0)),
|
||||
"down_count": int(r.get("下跌家数", 0)),
|
||||
"net_inflow": float(r.get("净流入", 0)),
|
||||
} for _, r in df.iterrows()]
|
||||
except Exception as e:
|
||||
print(f"⚠️ THS行業失敗: {e}")
|
||||
return []
|
||||
|
||||
def fetch_concept_ths():
|
||||
"""THS 概念板塊(僅名稱,無實時漲跌)"""
|
||||
try:
|
||||
import akshare as ak
|
||||
df = ak.stock_board_concept_name_ths()
|
||||
return [{"name": r["name"], "code": str(r["code"]), "change": 0}
|
||||
for _, r in df.head(15).iterrows()]
|
||||
except Exception as e:
|
||||
print(f"⚠️ THS概念失敗: {e}")
|
||||
return []
|
||||
|
||||
# ── 主流程 ──
|
||||
|
||||
def get_market_mood(sectors):
|
||||
if not sectors:
|
||||
return "unknown"
|
||||
ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors)
|
||||
return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish"
|
||||
|
||||
def main():
|
||||
sectors = fetch_sector_em()
|
||||
if sectors is None:
|
||||
sectors = fetch_sector_ths()
|
||||
|
||||
concepts = fetch_concept_em()
|
||||
if concepts is None:
|
||||
concepts = fetch_concept_ths()
|
||||
|
||||
sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True)
|
||||
top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5]
|
||||
top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3]
|
||||
|
||||
market_data = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"total_sectors": len(sectors),
|
||||
"up_ratio": round(sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1),
|
||||
"mood": get_market_mood(sectors),
|
||||
"top_gainers": top_gainers,
|
||||
"top_losers": top_losers,
|
||||
"sectors": sectors,
|
||||
"concepts": concepts,
|
||||
}
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f:
|
||||
json.dump(market_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 安静:数据只写文件,不打印到stdout,避免cron输出被推送
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""price_monitor.py — 高频价格监控脚本(批量版)
|
||||
规则:进入区间报一次,离开区间报一次,中间不重复。
|
||||
每次运行时一次性刷新所有持仓+自选股的实时价。
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
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"
|
||||
|
||||
UA = "Mozilla/5.0"
|
||||
|
||||
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_all_prices(codes):
|
||||
"""腾讯批量行情API:一次请求拉取所有股票(A股+港股)
|
||||
A股:sh600110 / sz000001
|
||||
港股:hk00700
|
||||
返回 {code: (price, change)}
|
||||
"""
|
||||
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
|
||||
results[orig_code] = (price, change)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def refresh_data_prices():
|
||||
"""一次性刷新portfolio.json和watchlist.json的所有实时价"""
|
||||
all_codes = set()
|
||||
|
||||
# 收集所有需要拉取的代码
|
||||
try:
|
||||
pf = json.load(open(PORTFOLIO_PATH))
|
||||
for s in pf.get('holdings', []):
|
||||
all_codes.add(s['code'])
|
||||
except:
|
||||
pf = {"holdings": []}
|
||||
|
||||
try:
|
||||
wl = json.load(open(WATCHLIST_PATH))
|
||||
for s in wl.get('stocks', []):
|
||||
all_codes.add(s['code'])
|
||||
except:
|
||||
wl = {"stocks": []}
|
||||
|
||||
if not all_codes:
|
||||
return 0
|
||||
|
||||
# 一次性批量拉取
|
||||
prices = fetch_all_prices(list(all_codes))
|
||||
updated = 0
|
||||
|
||||
# 更新portfolio(只在价格变化时写入,避免触发文件变更通知)
|
||||
changed = False
|
||||
for s in pf.get('holdings', []):
|
||||
if s['code'] in prices:
|
||||
price, _ = prices[s['code']]
|
||||
if price > 0:
|
||||
old = s.get('price', 0)
|
||||
if abs(old - price) > 0.001:
|
||||
s['price'] = round(price, 2)
|
||||
updated += 1
|
||||
changed = True
|
||||
if changed:
|
||||
json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
|
||||
# 更新watchlist(只在价格变化时写入)
|
||||
changed = False
|
||||
for s in wl.get('stocks', []):
|
||||
if s['code'] in prices:
|
||||
price, _ = prices[s['code']]
|
||||
if price > 0:
|
||||
old = s.get('price', 0)
|
||||
if abs(old - price) > 0.001:
|
||||
s['price'] = round(price, 2)
|
||||
updated += 1
|
||||
changed = True
|
||||
if changed:
|
||||
wl['updated_at'] = datetime.now().isoformat()
|
||||
json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2)
|
||||
|
||||
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 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"""
|
||||
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)
|
||||
|
||||
|
||||
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 run_once(round_label=""):
|
||||
"""执行一轮完整的监控流程"""
|
||||
label = f" [{round_label}]" if round_label else ""
|
||||
start = time.time()
|
||||
|
||||
# === 第一步:一次性刷新所有价格 ===
|
||||
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") == "active"]
|
||||
state = load_state()
|
||||
outputs = []
|
||||
state_updated = False
|
||||
|
||||
# 收集所有需要检查的代码
|
||||
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))
|
||||
|
||||
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] = {}
|
||||
|
||||
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))
|
||||
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)
|
||||
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
|
||||
|
||||
# === 第三步:输出 ===
|
||||
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)
|
||||
|
||||
|
||||
def main():
|
||||
"""每cron触发跑一轮"""
|
||||
run_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sync_dashboard.py — 数据同步包装脚本
|
||||
1. 运行 update_data.py
|
||||
2. 检查 server 是否正常
|
||||
3. server 挂了就重启
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
DASHBOARD_DIR = "/home/hmo/web-dashboard"
|
||||
SERVER_PORT = 8899
|
||||
|
||||
def run_update():
|
||||
"""运行 update_data.py,返回 stdout"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["python3", "update_data.py"],
|
||||
cwd=DASHBOARD_DIR,
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
return r.stdout.strip() if r.returncode == 0 else None
|
||||
except Exception as e:
|
||||
print(f"❌ update_data.py 执行失败: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def check_server():
|
||||
"""检查 server 是否正常"""
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.Request(f"http://localhost:{SERVER_PORT}",
|
||||
headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return r.status == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def restart_server():
|
||||
"""重启 server"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["python3", "server.py", str(SERVER_PORT)],
|
||||
cwd=DASHBOARD_DIR,
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ server 重启失败: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def main():
|
||||
output = run_update()
|
||||
server_ok = check_server()
|
||||
|
||||
if output:
|
||||
# update_data.py 有输出 → 转发
|
||||
print(output)
|
||||
if not server_ok:
|
||||
print("⚠️ 注意:server 似乎未响应,但数据已更新")
|
||||
else:
|
||||
if server_ok:
|
||||
# 无输出 + server 正常 → SILENT
|
||||
print("[SILENT] 数据无更新,server运行正常")
|
||||
else:
|
||||
# server 挂了 → 重启
|
||||
print("⚠️ server 无响应,尝试重启...")
|
||||
if restart_server():
|
||||
print("✅ server 已重启")
|
||||
# 再检查一次
|
||||
if check_server():
|
||||
print("✅ server 恢复运行")
|
||||
else:
|
||||
print("❌ server 仍未响应,需人工检查")
|
||||
else:
|
||||
print("❌ server 重启失败")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""system_health_check.py — MoFin 系统健康检查
|
||||
|
||||
每日运行,检查所有组件是否正常工作。
|
||||
输出报告,有问题才推送。
|
||||
"""
|
||||
import json, os, sys, subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path("/home/hmo/web-dashboard/data")
|
||||
DECISIONS_PATH = DATA_DIR / "decisions.json"
|
||||
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
|
||||
EVENTS_PATH = DATA_DIR / "price_events.json"
|
||||
EVALUATION_PATH = DATA_DIR / "evaluation.json"
|
||||
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
|
||||
CRON_JOBS = "/home/hmo/.hermes/cron/jobs.json"
|
||||
POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json"
|
||||
|
||||
def check(ok, msg):
|
||||
icon = "✅" if ok else "⚠️"
|
||||
return f" {icon} {msg}"
|
||||
|
||||
def load_json(path, default=None):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {} if default is None else default
|
||||
|
||||
def check_cron_jobs(path, label):
|
||||
issues = []
|
||||
try:
|
||||
d = load_json(path, {"jobs": []})
|
||||
for j in d.get("jobs", []):
|
||||
name = j.get("name", "?")
|
||||
enabled = j.get("enabled", True)
|
||||
last = j.get("last_run_at", "")
|
||||
status = j.get("last_status", "")
|
||||
if not enabled:
|
||||
issues.append(f"{name} 已禁用")
|
||||
elif not last:
|
||||
issues.append(f"{name} 从未运行")
|
||||
elif status != "ok":
|
||||
issues.append(f"{name} 上次状态={status}")
|
||||
return len(d.get("jobs", [])), issues
|
||||
except:
|
||||
return 0, ["无法读取"]
|
||||
|
||||
def run():
|
||||
now = datetime.now()
|
||||
issues = []
|
||||
ok_count = 0
|
||||
warn_count = 0
|
||||
|
||||
lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"]
|
||||
lines.append("")
|
||||
|
||||
# 1. 进程检查
|
||||
lines.append("【进程】")
|
||||
procs = {
|
||||
"mofin-dashboard": "mofin-dashboard",
|
||||
"xmpp-zhiwei": "xmpp_zhiwei_bot",
|
||||
"ejabberd": "ejabberd",
|
||||
}
|
||||
for name, pattern in procs.items():
|
||||
# 先查 systemd,再查 pgrep
|
||||
r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5)
|
||||
alive = r.stdout.strip() == "active"
|
||||
if not alive:
|
||||
r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5)
|
||||
alive = r2.returncode == 0
|
||||
lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}"))
|
||||
if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1
|
||||
else: ok_count += 1
|
||||
|
||||
# 2. 端口检查
|
||||
lines.append("")
|
||||
lines.append("【端口】")
|
||||
ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"}
|
||||
for port, name in ports.items():
|
||||
r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5)
|
||||
listening = f":{port}" in r.stdout
|
||||
lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}"))
|
||||
if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1
|
||||
else: ok_count += 1
|
||||
|
||||
# 3. 数据文件检查
|
||||
lines.append("")
|
||||
lines.append("【数据文件】")
|
||||
files = {
|
||||
"portfolio.json": PORTFOLIO_PATH,
|
||||
"watchlist.json": DATA_DIR / "watchlist.json",
|
||||
"decisions.json": DECISIONS_PATH,
|
||||
"market.json": DATA_DIR / "market.json",
|
||||
"price_events.json": EVENTS_PATH,
|
||||
"evaluation.json": EVALUATION_PATH,
|
||||
"accuracy_stats.json": ACCURACY_PATH,
|
||||
}
|
||||
for name, path in files.items():
|
||||
exists = path.exists()
|
||||
size = path.stat().st_size if exists else 0
|
||||
lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)"))
|
||||
if not exists or size < 10:
|
||||
issues.append(f"{name} 缺失或为空")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 4. 价格事件统计
|
||||
lines.append("")
|
||||
lines.append("【价格事件】")
|
||||
events = load_json(EVENTS_PATH, {"events": []})
|
||||
ev_list = events.get("events", [])
|
||||
today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")]
|
||||
lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条"))
|
||||
lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条"))
|
||||
if len(ev_list) == 0:
|
||||
issues.append("price_events.json 无事件记录,price_monitor可能未触发过")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 5. 策略评估统计
|
||||
lines.append("")
|
||||
lines.append("【策略评估】")
|
||||
evals = load_json(EVALUATION_PATH, {"strategies": []})
|
||||
s_list = evals.get("strategies", [])
|
||||
lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条"))
|
||||
if len(s_list) > 0:
|
||||
avg = sum(s.get("score", 0) for s in s_list) / len(s_list)
|
||||
lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10"))
|
||||
ok_count += 1
|
||||
else:
|
||||
issues.append("evaluation.json 无评估数据")
|
||||
warn_count += 1
|
||||
|
||||
# 6. 建议记录统计
|
||||
lines.append("")
|
||||
lines.append("【建议记录】")
|
||||
decisions = load_json(DECISIONS_PATH, {"decisions": []})
|
||||
total_advice = sum(len(d.get("advice_timeline", [])) for d in decisions.get("decisions", []))
|
||||
lines.append(check(total_advice > 0, f"建议记录: {total_advice}条"))
|
||||
if total_advice == 0:
|
||||
issues.append("所有策略建议记录为空")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 7. Cron jobs
|
||||
lines.append("")
|
||||
lines.append("【Cron Jobs】")
|
||||
cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default")
|
||||
lines.append(check(cnt > 0, f"default profile: {cnt}个job"))
|
||||
for ci in cron_issues:
|
||||
lines.append(f" ⚠️ {ci}")
|
||||
warn_count += 1
|
||||
if cnt == 0: warn_count += 1
|
||||
cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst")
|
||||
lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job"))
|
||||
for ci in cron_issues2:
|
||||
lines.append(f" ⚠️ {ci}")
|
||||
warn_count += 1
|
||||
if cnt2 == 0: warn_count += 1
|
||||
|
||||
# 8. 数据新鲜度
|
||||
lines.append("")
|
||||
lines.append("【数据新鲜度】")
|
||||
for name, path in [("portfolio.json", PORTFOLIO_PATH), ("decisions.json", DECISIONS_PATH)]:
|
||||
if path.exists():
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
hours_ago = (now - mtime).total_seconds() / 3600
|
||||
fresh = hours_ago < 24
|
||||
lines.append(check(fresh, f"{name} 更新于 {hours_ago:.0f}小时前"))
|
||||
if not fresh:
|
||||
issues.append(f"{name} 超过24小时未更新")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 汇总
|
||||
total = ok_count + warn_count
|
||||
lines.append("")
|
||||
lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注")
|
||||
if issues:
|
||||
lines.append("")
|
||||
lines.append("需关注项:")
|
||||
for i, issue in enumerate(issues[:10], 1):
|
||||
lines.append(f" {i}. {issue}")
|
||||
|
||||
report = "\n".join(lines)
|
||||
print(report)
|
||||
|
||||
# 如果有问题,写入报告文件供推送
|
||||
if warn_count > 0:
|
||||
report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health")
|
||||
report_path.mkdir(parents=True, exist_ok=True)
|
||||
report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md"
|
||||
report_file.write_text(f"# MoFin 系统健康检查\n\n{report}")
|
||||
print(f"\n报告已写入 {report_file}")
|
||||
else:
|
||||
print("\n[SILENT] 一切正常")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""update_data.py — 解析cron输出,更新dashboard数据层"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
|
||||
def _save(name, data):
|
||||
path = DATA_DIR / name
|
||||
os.makedirs(path.parent, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def parse_report(markdown_text, source_file=None):
|
||||
"""解析cron输出的markdown报告,提取结构化数据"""
|
||||
report = {
|
||||
"title": "",
|
||||
"type": "未知",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"summary": "",
|
||||
"content": markdown_text,
|
||||
"stocks_mentioned": [],
|
||||
"structured": None, # 结构化数据优先
|
||||
}
|
||||
|
||||
lines = markdown_text.split("\n")
|
||||
|
||||
# 提取标题
|
||||
for line in lines:
|
||||
m = re.match(r"^#\s+(.+)", line)
|
||||
if m:
|
||||
report["title"] = m.group(1).strip()
|
||||
break
|
||||
m = re.match(r"^📊\s+(.+)", line)
|
||||
if m:
|
||||
report["title"] = m.group(1).strip()
|
||||
break
|
||||
|
||||
# 判断类型
|
||||
if "盘中" in report["title"]:
|
||||
report["type"] = "盘中"
|
||||
elif "盘后" in report["title"] or "复盘" in report["title"]:
|
||||
report["type"] = "盘后"
|
||||
elif "盯盘" in report["title"]:
|
||||
report["type"] = "盯盘"
|
||||
elif "扫描" in report["title"]:
|
||||
report["type"] = "盘前"
|
||||
|
||||
# ★ 优先提取结构化JSON(如果知微输出了的话)
|
||||
struct_match = re.search(r'<structured_data>\s*(\{.*?\})\s*</structured_data>', markdown_text, re.DOTALL)
|
||||
if struct_match:
|
||||
try:
|
||||
parsed = json.loads(struct_match.group(1))
|
||||
report["structured"] = parsed
|
||||
# 从结构化数据中直接取stock codes
|
||||
codes = set()
|
||||
for h in parsed.get("holdings", []):
|
||||
c = h.get("code", "")
|
||||
if c:
|
||||
codes.add(c)
|
||||
report["stocks_mentioned"] = sorted(codes)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
pass # JSON解析失败→走NLP兜底
|
||||
|
||||
# 摘要(前3非空行)
|
||||
body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")]
|
||||
report["summary"] = "\n".join(body_lines[:5])[:200]
|
||||
|
||||
# NLP兜底(仅当结构化数据没取到code时)
|
||||
if not report["stocks_mentioned"]:
|
||||
codes = set(re.findall(r'\b\d{6}\b', markdown_text))
|
||||
hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text))
|
||||
report["stocks_mentioned"] = sorted(codes | hk_codes)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def import_cron_outputs():
|
||||
"""从cron输出目录导入最新报告"""
|
||||
cron_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
reports_dir = DATA_DIR / "reports"
|
||||
os.makedirs(reports_dir, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
if not cron_dir.exists():
|
||||
return count
|
||||
|
||||
for job_dir in sorted(cron_dir.iterdir()):
|
||||
if not job_dir.is_dir():
|
||||
continue
|
||||
for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个
|
||||
if f.suffix != ".md":
|
||||
continue
|
||||
# Skip if already imported
|
||||
export_name = f"cron_{job_dir.name}_{f.stem}.json"
|
||||
if (reports_dir / export_name).exists():
|
||||
continue
|
||||
|
||||
content = f.read_text(encoding="utf-8", errors="replace")
|
||||
report = parse_report(content, source_file=str(f))
|
||||
|
||||
# Extract response section
|
||||
resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL)
|
||||
if resp_match:
|
||||
resp = resp_match.group(1).strip()
|
||||
if resp == "[SILENT]":
|
||||
continue # Skip SILENT reports
|
||||
|
||||
report["_id"] = export_name.replace(".json", "")
|
||||
_save(f"reports/{export_name}", report)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def extract_stock_mentions():
|
||||
"""从报告中提取个股操作建议"""
|
||||
reports_dir = DATA_DIR / "reports"
|
||||
stocks_dir = DATA_DIR / "stocks"
|
||||
os.makedirs(stocks_dir, exist_ok=True)
|
||||
|
||||
stock_data = {}
|
||||
|
||||
for f in sorted(reports_dir.iterdir()):
|
||||
if f.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
report = json.loads(f.read_text(encoding="utf-8"))
|
||||
except:
|
||||
continue
|
||||
|
||||
content = report.get("content", "")
|
||||
codes = report.get("stocks_mentioned", [])
|
||||
|
||||
for code in codes:
|
||||
if code not in stock_data:
|
||||
stock_data[code] = {"code": code, "history": []}
|
||||
|
||||
# Try to extract recommendation from content
|
||||
# Look for patterns like "建议|止盈|止损|补仓|持有"
|
||||
pattern = re.compile(
|
||||
rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for m in pattern.finditer(content):
|
||||
stock_data[code]["history"].append({
|
||||
"time": report.get("created_at", ""),
|
||||
"content": m.group(0).strip()[:100],
|
||||
"report_id": report.get("_id", ""),
|
||||
})
|
||||
|
||||
for code, data in stock_data.items():
|
||||
_save(f"stocks/{code}.json", data)
|
||||
|
||||
return len(stock_data)
|
||||
|
||||
|
||||
def sync_to_decisions():
|
||||
"""将个股建议同步到决策库(advice_timeline),自动去重"""
|
||||
decisions_path = DATA_DIR / "decisions.json"
|
||||
if not decisions_path.exists():
|
||||
return 0
|
||||
|
||||
decisions = json.loads(decisions_path.read_text(encoding="utf-8"))
|
||||
stocks_dir = DATA_DIR / "stocks"
|
||||
synced = 0
|
||||
|
||||
for f in sorted(stocks_dir.iterdir()):
|
||||
if f.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
stock = json.loads(f.read_text(encoding="utf-8"))
|
||||
except:
|
||||
continue
|
||||
|
||||
code = stock.get("code", "")
|
||||
history = stock.get("history", [])
|
||||
if not code or not history:
|
||||
continue
|
||||
|
||||
# 找决策库中是否有此股
|
||||
existing = None
|
||||
for d in decisions["decisions"]:
|
||||
if d["code"] == code:
|
||||
existing = d
|
||||
break
|
||||
|
||||
if not existing:
|
||||
# 无决策记录→生成inactive记录
|
||||
existing = {
|
||||
"code": code,
|
||||
"name": stock.get("name", ""),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"type": "历史建议汇总",
|
||||
"current": "自动从update_data同步",
|
||||
"status": "inactive",
|
||||
"updated_by": "system(update_data)",
|
||||
"advice_timeline": []
|
||||
}
|
||||
decisions["decisions"].append(existing)
|
||||
|
||||
# 去重合并
|
||||
timeline = existing.setdefault("advice_timeline", [])
|
||||
existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline
|
||||
if "date" in e and "direction" in e and "summary" in e}
|
||||
|
||||
new_count = 0
|
||||
for entry in history:
|
||||
content = entry.get("content", "")
|
||||
# 判断方向
|
||||
direction = "其他"
|
||||
if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]):
|
||||
direction = "买入"
|
||||
elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]):
|
||||
direction = "卖出"
|
||||
elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]):
|
||||
direction = "持有"
|
||||
|
||||
if direction == "其他":
|
||||
continue
|
||||
|
||||
# 提取日期
|
||||
rid = entry.get("report_id", "")
|
||||
m = re.search(r'(\d{4}-\d{2}-\d{2})', rid)
|
||||
date = m.group(1) if m else "unknown"
|
||||
|
||||
key = (date, direction, content.strip()[:80])
|
||||
if key not in existing_keys:
|
||||
existing_keys.add(key)
|
||||
timeline.append({
|
||||
"date": date,
|
||||
"direction": direction,
|
||||
"summary": content.strip()[:120],
|
||||
"report_id": rid
|
||||
})
|
||||
new_count += 1
|
||||
|
||||
if new_count > 0:
|
||||
# 按日期排序
|
||||
timeline.sort(key=lambda e: e.get("date", ""))
|
||||
synced += new_count
|
||||
|
||||
decisions_path.write_text(
|
||||
json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return synced
|
||||
|
||||
|
||||
def build_portfolio_from_obsidian():
|
||||
"""读取Obsidian持仓数据,生成portfolio.json"""
|
||||
import subprocess
|
||||
# Attempt to read from Obsidian
|
||||
obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance"
|
||||
portfolio_file = obsidian_path / "dad-portfolio.md"
|
||||
|
||||
holdings = []
|
||||
total_assets = 0
|
||||
stock_value = 0
|
||||
cash = 0
|
||||
|
||||
if portfolio_file.exists():
|
||||
content = portfolio_file.read_text(encoding="utf-8", errors="replace")
|
||||
lines = content.split("\n")
|
||||
|
||||
for line in lines:
|
||||
m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line)
|
||||
if m:
|
||||
# Parse holding lines from markdown table
|
||||
parts = [p.strip() for p in line.split("|")]
|
||||
if len(parts) >= 8:
|
||||
name = parts[1] if len(parts) > 1 else ""
|
||||
code = parts[2] if len(parts) > 2 else ""
|
||||
if code:
|
||||
holdings.append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"position_pct": 0,
|
||||
"cost": 0,
|
||||
"shares": 0,
|
||||
"price": 0,
|
||||
"change_pct": 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"holdings": holdings,
|
||||
"total_assets": total_assets,
|
||||
"stock_value": stock_value,
|
||||
"cash": cash,
|
||||
"position_pct": 0,
|
||||
"total_pnl": 0,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
count = import_cron_outputs()
|
||||
if count > 0:
|
||||
print(f"📥 新增报告: {count}篇")
|
||||
|
||||
stocks = extract_stock_mentions()
|
||||
if stocks > 0:
|
||||
print(f"📊 个股数据: {stocks}条")
|
||||
|
||||
synced = sync_to_decisions()
|
||||
if synced > 0:
|
||||
print(f"📋 决策库: 新增{synced}条建议")
|
||||
|
||||
# 有实质更新才发汇总,否则安静
|
||||
if count > 0 or stocks > 0 or synced > 0:
|
||||
print(f"✅ {datetime.now().strftime('%m/%d %H:%M')} 数据同步完成")
|
||||
# 什么新数据都没有→安静,不输出任何内容
|
||||
Reference in New Issue
Block a user