From de9927a6278f7e6c531ed9d06d539d4ab00f5cf0 Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 20 Jul 2026 23:46:40 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E8=AF=84=E6=A0=B8=E5=BF=83=E9=87=8D?= =?UTF-8?q?=E6=9E=84=EF=BC=9Ads-v4-pro=20+=20=E5=8E=9F=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=85=A8=E6=96=87=20+=20strategy=5Fhistory=20+=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=89=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(重评管线): - 新增 llm_client.py 共享客户端: REASSESS_MODEL=deepseek-v4-pro 单点, gateway预检(fail-fast), 150s超时+1次重试, 永不抛异常 - batch_reassess/per_stock_reassess: curl/urllib -> call_llm, prompt传入原策略全文+当前参数+最近3条变更, 输出 维持/修改判断+ 修改点理由+最终新策略, max_tokens 4096 - mofin_db: 新增 strategy_history 表 + snapshot_strategy_history(), write_holding_strategy 覆写前自动快照(保留20条/code) - mofin_db: holding_strategies 补 tag 列迁移 + 写入保留 (tag缺席=保留旧值, 显式传''=允许清除), 修复推荐标签被静默丢弃 - mo_data.read_decisions: SELECT 补 tag - stale_detector/promote_candidates: 子进程超时 240/60 -> 480s 前端: - 移除 报告Tab -> mofin_health 全部流程/Cron 表加 最后十次 列 (modal列表->详情), /api/reports 支持 cron+script 多路匹配 (jobs.json name->id 解析 + 文件名/标题子串兜底) - 移除 决策库Tab - 盯盘Tab 重构: 全部持仓+自选, sort_group 分组(推荐/持仓/自选), 推荐行琥珀高亮+🔥badge+行内策略, 新增 操作策略 列查看 最近3次完整策略(/api/strategy_history/, 表缺失时降级当前行) - 提示词Tab: registry.py 数据路径改回 /home/hmo/MoFin/data/prompts (红线: 数据只在规范数据根), 空态提示初始化命令 --- deploy/profile-scripts/batch_reassess.py | 241 +++++-- deploy/profile-scripts/llm_client.py | 133 ++++ deploy/profile-scripts/per_stock_reassess.py | 111 ++- deploy/profile-scripts/promote_candidates.py | 35 +- deploy/profile-scripts/stale_detector.py | 6 +- mofin_db.py | 95 ++- prompt_manager/registry.py | 2 +- scripts/mo_data.py | 2 +- server.py | 228 +++++- static/index.html | 687 +++++++------------ static/mofin_health.html | 97 ++- 11 files changed, 1037 insertions(+), 600 deletions(-) create mode 100644 deploy/profile-scripts/llm_client.py diff --git a/deploy/profile-scripts/batch_reassess.py b/deploy/profile-scripts/batch_reassess.py index 196dab1b..d7a3d546 100644 --- a/deploy/profile-scripts/batch_reassess.py +++ b/deploy/profile-scripts/batch_reassess.py @@ -1,19 +1,30 @@ #!/usr/bin/env python3 -"""batch_reassess.py — 批量补全九维分析(逐只处理,间隔防限流) +"""batch_reassess.py — 批量补全12维(九维矩阵)LLM分析(逐只处理,间隔防限流) -用法: python3 batch_reassess.py [--all] [--code XXXXXX] +用法: + 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)写九维分析+策略 → 保存到DB +流程:收集最新数据 → 调LLM(gateway)写12维分析+策略 → 保存到DB """ -import sys, json, subprocess, sqlite3, re, time +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, gateway_alive +from mofin_db import snapshot_strategy_history + DB = "/home/hmo/MoFin/data/mofin.db" -GATEWAY = "http://127.0.0.1:8643/v1/chat/completions" COOLDOWN_HOURS = 1 +STALE_HOURS = 20 # 分析超过20小时视为过期,需要重评 def has_llm_analysis(code): - """检查是否为LLM生成的九维分析(>500字)""" + """检查是否为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() @@ -33,13 +44,41 @@ def in_cooldown(code): 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读策略 + # 从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 FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + 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 @@ -52,10 +91,21 @@ def collect_data(code): 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拉最新价和基本面 - prefix = "sh" if str(code).startswith(("6","9")) else "sz" + # 代码前缀: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("~") @@ -80,9 +130,10 @@ def collect_data(code): return data def build_prompt(data): - """构建LLM prompt,要求输出完整策略""" - cash = 321271 # 可用现金(从DB读取) - total = 952879 # 总资产 + """构建LLM prompt,先审阅原策略再结合实时数据输出修改判断+九维矩阵分析""" + cash, total = get_portfolio() + if not total: + cash, total = 241330, 929727 # 兜底(DB读不到时) # 拉取资金流数据 _flow_note = "暂无资金流数据" @@ -122,7 +173,53 @@ def build_prompt(data): except: pass - return f"""你是一个资深A股分析师。请对{data['code']} {data.get('name','')}做一个完整的九维矩阵分析,并输出策略参数。 + # ── 构建【原策略全文】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个维度不是独立分析的,你必须交叉对比后给出综合结论。 例如:如果消息面利好但资金流在流出,说明利好可能是出货;如果基本面强但技术面破位,说明估值可能还没到底。 @@ -136,11 +233,18 @@ PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 资金流:{_flow_note}(近5日累计) 消息面:{_news_note}(最近3条,自动标注抓取时间) 当前信号:{data.get('timing_signal','?')} 分类:{data.get('stock_category','?')} -原策略:{(data.get('action','') or '')[:200]} 我的总资产={total}元,可用现金={cash}元。 -请严格按以下格式输出: +请严格按以下格式输出(注意节标题不可省略): + +【维持或修改】明确二选一判断:维持原策略 / 需要修改策略 +【修改点及理由】 +如果维持原策略 → 写"无需修改" +如果需要修改 → 逐条列出(每条格式:"- 修改点名称:理由说明") +【最终新策略】 +用自然语言输出完整的最终策略全文(200-400字),自包含核心交易逻辑、买入区间价格、止损价、止盈价、仓位比例、风险提示。 +⚠️ 本段不要使用【综合结论】【买入区间】等标签——用自然语言描述即可。 【交叉分析】用2-3句话说明哪些维度出现矛盾/共振,最关键的信号是什么 ① 大盘×基本面 [一句话,说明矛盾关系] @@ -217,10 +321,13 @@ def parse_response(text): return result def save_result(code, full_text, parsed): - """保存LLM结果到DB""" + """保存LLM结果到DB(先快照再UPDATE)""" conn = sqlite3.connect(DB) now = datetime.now().isoformat() + # ── 修改前快照 ── + snapshot_strategy_history(conn, code, 'batch_12d') + updates = ["full_analysis=?", "reassessed_at=?"] params = [full_text, now] @@ -259,107 +366,109 @@ def save_result(code, full_text, parsed): _sl = parsed.get("stop_loss", 0) _tp = parsed.get("take_profit", 0) _pos = parsed.get("position", "") - _msg = f"📈 {_name}({code}) 价{_p}→12维分析生成买入信号!区间{_el}~{_eh} 损{_sl} 盈{_tp} 仓位{_pos}" + _msg = f"\U0001f4c8 {_name}({code}) 价{_p}\u219212维分析生成买入信号!区间{_el}~{_eh} 损{_sl} 盈{_tp} 仓位{_pos}" import urllib.request, json as _jj _req = urllib.request.Request("http://127.0.0.1:5805/", data=_jj.dumps({"body": _msg, "to": "hmo@yoin.fun", "type": "chat"}).encode(), headers={"Content-Type": "application/json"}) urllib.request.urlopen(_req, timeout=5) - print(f" 📨 XMPP推送成功: {_msg[:60]}") + print(f" \U0001f4e8 XMPP推送成功: {_msg[:60]}") except Exception as _e: - print(f" ⚠️ XMPP推送失败: {_e}") + print(f" \u26a0\ufe0f XMPP推送失败: {_e}") conn.close() -def process_stock(code): +def process_stock(code, force_today=False): """处理单只股票""" print(f"\n{'='*50}") print(f"处理: {code}") print(f"{'='*50}") - if has_llm_analysis(code): - print(f" ⏭ 已有LLM九维分析,跳过") + if in_cooldown(code): + print(f" \u23ed 冷却期内,跳过") return False - if in_cooldown(code): - print(f" ⏭ 冷却期内,跳过") + # 有分析且未过期 \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" ⚠️ 无价格数据,跳过") + print(f" \u26a0\ufe0f 无价格数据,跳过") return False print(f" 调LLM生成九维分析...", flush=True) prompt = build_prompt(data) - try: - r = subprocess.run(["curl", "-s", "--max-time", "300", - "-H", "Content-Type: application/json", - "-H", "Authorization: Bearer hermes123", - "-d", json.dumps({"model":"deepseek-v4-flash","messages":[{"role":"user","content":prompt}],"max_tokens":2048}), - GATEWAY], capture_output=True, timeout=310) - - if r.returncode != 0: - print(f" ❌ curl失败: {r.stderr.decode()[:100]}") - return False - - resp = json.loads(r.stdout) - if "choices" not in resp: - print(f" ❌ API异常: {str(resp)[:200]}") - return False - - full_text = resp["choices"][0]["message"]["content"] - print(f" ✅ LLM返回({len(full_text)}字)", flush=True) - - parsed = parse_response(full_text) - print(f" 信号={parsed['signal']} 区间={parsed['entry_low']}~{parsed['entry_high']} 损={parsed['stop_loss']} 盈={parsed['take_profit']} 仓位={parsed['position']}") - - save_result(code, full_text, parsed) - print(f" ✅ 已保存到DB") - return True - - except subprocess.TimeoutExpired: - print(f" ❌ 超时") - return False - except Exception as e: - print(f" ❌ 错误: {e}") + # ── 使用共享 LLM 客户端(替代 curl subprocess)── + result = call_llm(prompt, model=REASSESS_MODEL, max_tokens=4096) + + if not result["ok"]: + print(f" \u274c LLM调用失败: {result.get('error','未知错误')}") 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) + 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(): + # ── Gateway 预检:不可用则立即退出(不阻塞 cron)── + if not gateway_alive(): + print("[FATAL] Hermes Gateway 不可用,退出(检查 http://127.0.0.1:8643/v1/models)") + sys.exit(1) + 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) - rows = conn.execute("SELECT code FROM holding_strategies WHERE status='active' AND decision_type='自选策略' ORDER BY code").fetchall() + 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)}只") + print(f"待处理: {len(codes)}只 (type={dtype or 'all'}, force_today={force_today})") ok = 0 fail = 0 skip = 0 for i, code in enumerate(codes): - if has_llm_analysis(code): - print(f" [{i+1}/{len(codes)}] ⏭ {code} 已有LLM分析") + 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): + if process_stock(code, force_today): ok += 1 else: fail += 1 - # 间隔15秒(防gateway过载) + # 间隔8秒(pro model较重但gateway可承受;retry逻辑吸收瞬断) if i < len(codes) - 1: - print(f" 等待15秒...", flush=True) - time.sleep(15) + print(f" 等待8秒...", flush=True) + time.sleep(8) print(f"\n{'='*50}") print(f"完成: {ok}成功, {fail}失败, {skip}跳过") diff --git a/deploy/profile-scripts/llm_client.py b/deploy/profile-scripts/llm_client.py new file mode 100644 index 00000000..883d9918 --- /dev/null +++ b/deploy/profile-scripts/llm_client.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""llm_client.py — 共享 LLM 客户端(重试 + gateway 预检) + +所有重评脚本统一通过此模块调用 LLM gateway,避免重复的 HTTP/重试逻辑。 + +用法: + from llm_client import call_llm, REASSESS_MODEL, gateway_alive + if not gateway_alive(): + print("Gateway 不可用,退出") + sys.exit(1) + result = call_llm(prompt) + if result["ok"]: + full_text = result["content"] +""" + +import json +import time +import urllib.request +import urllib.error + +# ── 常量:所有重评调用统一使用 ── +REASSESS_MODEL = "deepseek-v4-pro" +GATEWAY = "http://127.0.0.1:8643/v1/chat/completions" +GATEWAY_BASE = "http://127.0.0.1:8643/v1/models" +AUTH = "Bearer hermes123" + + +def gateway_alive(timeout=5): + """快速预检 gateway 是否存活。失败立刻返回 False,不阻塞。""" + try: + req = urllib.request.Request(GATEWAY_BASE, headers={"Authorization": AUTH}) + urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=timeout) + return True + except Exception: + return False + + +def call_llm(prompt, model=None, max_tokens=4096, timeout=150, + retries=1, backoff=20, system=None): + """调用 LLM gateway,带重试和结构化日志。 + + Args: + prompt: 用户消息内容 + model: 模型名(默认 REASSESS_MODEL) + max_tokens: 最大输出 token 数 + timeout: 单次调用超时(秒) + retries: 超时/5xx/连接错误时的重试次数 + backoff: 重试间隔(秒) + system: 可选 system message + + Returns: + {ok: bool, content: str, error: str|None, model: str, + elapsed: float, attempts: int} + 永远不抛异常到调用方。 + """ + model_name = model or REASSESS_MODEL + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + payload = json.dumps({ + "model": model_name, + "messages": messages, + "max_tokens": max_tokens, + }).encode() + + for attempt in range(retries + 1): + t0 = time.monotonic() + try: + req = urllib.request.Request( + GATEWAY, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": AUTH, + } + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + resp = opener.open(req, timeout=timeout) + elapsed = time.monotonic() - t0 + + body = json.loads(resp.read().decode()) + if "choices" not in body: + msg = f"API响应无choices字段: {str(body)[:200]}" + print(f" [LLM] 尝试{attempt+1}/{retries+1} 失败({elapsed:.1f}s): {msg}", flush=True) + if attempt < retries: + time.sleep(backoff) + continue + + content = body["choices"][0]["message"]["content"] + print(f" [LLM] 尝试{attempt+1}/{retries+1} 成功, {elapsed:.1f}s, " + f"输出{len(content)}字, model={model_name}", flush=True) + return { + "ok": True, + "content": content, + "error": None, + "model": model_name, + "elapsed": elapsed, + "attempts": attempt + 1, + } + + except urllib.error.URLError as e: + elapsed = time.monotonic() - t0 + err_msg = str(e) + print(f" [LLM] 尝试{attempt+1}/{retries+1} 连接失败({elapsed:.1f}s): {err_msg[:120]}", flush=True) + if attempt < retries: + print(f" [LLM] 等待{backoff}s后重试...", flush=True) + time.sleep(backoff) + else: + return { + "ok": False, "content": "", "error": f"连接失败(重试{retries}次): {err_msg[:200]}", + "model": model_name, "elapsed": elapsed, "attempts": attempt + 1, + } + + except Exception as e: + elapsed = time.monotonic() - t0 + err_msg = str(e) + print(f" [LLM] 尝试{attempt+1}/{retries+1} 失败({elapsed:.1f}s): {err_msg[:120]}", flush=True) + if attempt < retries: + print(f" [LLM] 等待{backoff}s后重试...", flush=True) + time.sleep(backoff) + else: + return { + "ok": False, "content": "", "error": f"调用失败(重试{retries}次): {err_msg[:200]}", + "model": model_name, "elapsed": elapsed, "attempts": attempt + 1, + } + + # Unreachable + return { + "ok": False, "content": "", "error": "未预期的调用结束", + "model": model_name, "elapsed": 0, "attempts": retries + 1, + } diff --git a/deploy/profile-scripts/per_stock_reassess.py b/deploy/profile-scripts/per_stock_reassess.py index c5c1d172..1979b0da 100644 --- a/deploy/profile-scripts/per_stock_reassess.py +++ b/deploy/profile-scripts/per_stock_reassess.py @@ -34,8 +34,11 @@ def _in_cooldown(code): 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): @@ -431,18 +434,76 @@ def main(): except: pass - _prompt = f"""你是一个资深股票分析师。请对股票{code}做一个完整的12维矩阵分析(3横×4纵:大盘/行业/个股 × 基本面/消息面/技术面/资金面)。 + # ── 拉取已有策略全文 + 最近变更 ── + _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个维度必须交叉对比,找出矛盾/共振点,给出综合判断。 -当前数据(实时API,每条标注时间窗口,禁止使用模型训练数据): -大盘={_macro_desc or "震荡"}(当日实时) | PE/市值={_pe_val} {_pb_val}(最新财报) | 价格={price} 区间={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("timing_signal") or entry.get("timing_signal","")} | 行业={(result.get("sector_context") or entry.get("sector_context",""))[:50]}(当日实时) -策略={(result.get("action") or entry.get("action",""))[:200]} -技术={(result.get("tech_snapshot") or entry.get("tech_snapshot",""))[:200]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) +当前实时数据(每条标注时间窗口,禁止使用模型训练数据): +大盘={_macro_desc or "震荡"}(当日实时) | PE/市值={_pe_val} {_pb_val}(最新财报) 资金流={_flow_note}(近5日累计) 消息面={_news_note}(最近3条,自动标注抓取时间) -格式: +╔══════════════════════════════════════════════╗ +║ 📝 第三步:决策输出 ║ +╚══════════════════════════════════════════════╝ + +请严格按以下顺序输出: + +【维持或修改】判断当前策略是否仍然有效,回答「维持」或「修改」。 + +【修改点及理由】(如果维持,写「无需修改」;如果修改,逐条列出): + - 修改什么参数/方向 + - 理由(引用具体维度矛盾或共振) + +【最终新策略】(完整策略全文,self-contained,可直接存入DB) + 【交叉分析】哪些维度矛盾/共振,关键信号 ① 大盘×基本面 ② 大盘×消息面 ③ 大盘×技术面 ④ 大盘×资金面 ⑤ 行业×基本面 ⑥ 行业×消息面 ⑦ 行业×技术面 ⑧ 行业×资金面 @@ -453,25 +514,35 @@ def main(): 【操作建议】 【建议止损】 【建议止盈】""" + _full_analysis_text = None try: - _ur = __import__('urllib.request', fromlist=['Request']) - _req = _ur.Request("http://127.0.0.1:8643/v1/chat/completions", - data=__import__('json').dumps({"model":"deepseek-v4-flash","messages":[{"role":"user","content":_prompt}],"max_tokens":1024}).encode(), - headers={"Content-Type":"application/json","Authorization":"Bearer hermes123"}) - _resp = _ur.build_opener(_ur.ProxyHandler({})).open(_req, timeout=300) - _llm_out = __import__('json').loads(_resp.read().decode())["choices"][0]["message"]["content"] - _full_analysis_text = _llm_out - print(f" ✅ LLM12维分析完成({len(_full_analysis_text)}字)", flush=True) + _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}", file=__import__('sys').stderr) - _full_analysis_text = None + print(f" ❌ LLM12维分析异常: {_e}", flush=True) - # 保存到DB + # ── 保存到DB(覆写前先快照)── _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _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() + 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() - print(f" ✅ 完整12维分析已保存({len(_full_analysis_text)}字)" if _full_analysis_text else f" ⚠️ 12维分析未完成,跳过保存") + 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: diff --git a/deploy/profile-scripts/promote_candidates.py b/deploy/profile-scripts/promote_candidates.py index 6eebf8a7..c99cebcf 100644 --- a/deploy/profile-scripts/promote_candidates.py +++ b/deploy/profile-scripts/promote_candidates.py @@ -84,8 +84,8 @@ def main(): reason_text.append(f"评分{score}") action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})" - conn.execute(""" - INSERT INTO holding_strategies + cur = conn.execute(""" + INSERT OR IGNORE INTO holding_strategies (code, name, price, entry_low, entry_high, stop_loss, take_profit, timing_signal, action, decision_type, strategy_type, status, rr_ratio, stock_category, created_at, updated_at, @@ -93,22 +93,27 @@ def main(): VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan', 'active',0,'关注',?,?,'', 'pending') """, (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now)) + newly_added = cur.rowcount > 0 conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,)) - promoted += 1 - print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True) + if newly_added: + promoted += 1 + print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True) + else: + print(f" ⏭ {code} {name} 已在自选策略中,标记promoted", flush=True) - # 触发全量重评(生成完整9维策略) - try: - import subprocess as _sp - r = _sp.run(["python3", "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], - capture_output=True, text=True, timeout=60) - if r.returncode == 0: - print(f" 重评完成", flush=True) - else: - print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True) - except Exception as e: - print(f" 重评异常: {e}", flush=True) + # 触发全量重评(生成完整9维策略)——仅新插入的股票需要 + if newly_added: + try: + import subprocess as _sp + r = _sp.run(["python3", "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], + capture_output=True, text=True, timeout=480) + if r.returncode == 0: + print(f" 重评完成", flush=True) + else: + print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True) + except Exception as e: + print(f" 重评异常: {e}", flush=True) conn.commit() print(f"\n[PROMOTE] 本次提拔{promoted}只", flush=True) diff --git a/deploy/profile-scripts/stale_detector.py b/deploy/profile-scripts/stale_detector.py index dd86e9b2..fc805b83 100644 --- a/deploy/profile-scripts/stale_detector.py +++ b/deploy/profile-scripts/stale_detector.py @@ -156,14 +156,14 @@ def main(): print(f"[AUTO_REASSESS] 本轮限{MAX_PER_RUN}只,剩余{len(reassess_scripts)-MAX_PER_RUN}只下轮继续") for code in batch: try: - # LLM 重评冷启动 20-100s,60s 必死(37只全灭那次的根因)→ 240s + # LLM 重评冷启动 20-100s,deepseek-v4-pro 更慢 → 480s r = subprocess.run(['python3', reassess_path, code], - capture_output=True, text=True, timeout=240) + capture_output=True, text=True, timeout=480) out = r.stdout.strip()[:200] if r.stdout else "" err = r.stderr.strip()[:200] if r.stderr else "" print(f" → {code}: exited={r.returncode} {out}") except subprocess.TimeoutExpired: - print(f" → {code}: 超时240s(LLM仍慢),下轮重试") + print(f" → {code}: 超时480s(LLM仍慢),下轮重试") except Exception as e: print(f"[AUTO_REASSESS FAIL] {e}") # ----- 结束 自选股重评 ----- diff --git a/mofin_db.py b/mofin_db.py index 9cdec1e9..98ff7177 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -262,6 +262,29 @@ def init_all_tables(conn: sqlite3.Connection): CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); + -- 策略历史快照(每次覆写前自动记录) + CREATE TABLE IF NOT EXISTS strategy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT, + decision_type TEXT, + strategy_type TEXT, + full_analysis TEXT, + action TEXT, + timing_signal TEXT, + entry_low REAL, + entry_high REAL, + stop_loss REAL, + take_profit REAL, + position_advice TEXT, + rr_ratio REAL, + version INTEGER, + source_trigger TEXT, + reassessed_at TEXT, + snapshotted_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_strategy_history_code ON strategy_history(code, snapshotted_at); + -- 自选股 CREATE TABLE IF NOT EXISTS watchlist_stocks ( code TEXT PRIMARY KEY REFERENCES stocks(code), @@ -551,6 +574,14 @@ def init_all_tables(conn: sqlite3.Connection): conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") except sqlite3.OperationalError: pass # column already exists + + # ── tag 迁移(2026-07-20):推荐标签 current_recommend / active_manual ── + # 此前 strategy_lifecycle 在 dict 里设置 tag 但 write_holding_strategy 无此列, + # 导致标签在写入时被静默丢弃。补列 + 写入保留。 + try: + conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass conn.commit() @@ -1077,9 +1108,52 @@ def get_prices_batch_from_db(codes: list[str]) -> dict: # 核心写函数 — 替代 json.dump(),强制币种约束 # ═══════════════════════════════════════════════════════════════════ -def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool, str]: +def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"): + """在修改前快照当前策略到 strategy_history 表。永不抛异常。""" + try: + row = conn.execute( + "SELECT code, name, decision_type, strategy_type, full_analysis, " + "action, timing_signal, entry_low, entry_high, stop_loss, take_profit, " + "position_advice, rr_ratio, version, reassessed_at " + "FROM holding_strategies WHERE code=? AND status='active'", + (code,) + ).fetchone() + if not row: + return + now = datetime.now().isoformat() + conn.execute(""" + INSERT INTO strategy_history + (code, name, decision_type, strategy_type, full_analysis, action, + timing_signal, entry_low, entry_high, stop_loss, take_profit, + position_advice, rr_ratio, version, source_trigger, reassessed_at, snapshotted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + row[0], row[1], row[2], row[3], + row[4], row[5], row[6], + row[7], row[8], row[9], row[10], + row[11], row[12], row[13], + source_trigger, row[14], now + )) + conn.commit() + # 每只股票只保留最近20条历史 + conn.execute(""" + DELETE FROM strategy_history WHERE code=? AND id NOT IN ( + SELECT id FROM strategy_history WHERE code=? ORDER BY snapshotted_at DESC LIMIT 20 + ) + """, (code, code)) + conn.commit() + except Exception as e: + print(f" [SNAPSHOT] {code} 快照失败: {e}", flush=True) + + +def write_holding_strategy(conn, code: str, name: str, data: dict, + source_trigger: str = "write_holding_strategy") -> tuple[bool, str]: """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" try: + # ── 覆写前快照旧行 ── + snapshot_strategy_history(conn, code, source_trigger) + + currency = data.get('currency', 'CNY') # Serialize JSON fields import json as _json @@ -1091,12 +1165,18 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool # 在DELETE前保留现有的full_analysis和reassessed_at(防止被regenerate_all等清空) _existing_fa = data.get('full_analysis', '') _existing_ra = data.get('reassessed_at', '') - if not _existing_fa: + # tag 语义:'tag' 键缺席=保留旧标签;显式传入(含'')= 按传入值(允许清除标签) + _tag_absent = 'tag' not in data + _existing_tag = data.get('tag', '') or '' + if not _existing_fa or _tag_absent: try: - _old = conn.execute("SELECT full_analysis, reassessed_at FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() + _old = conn.execute("SELECT full_analysis, reassessed_at, tag FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() if _old: - if _old[0]: _existing_fa = _old[0] - if _old[1]: _existing_ra = _old[1] + if not _existing_fa: + if _old[0]: _existing_fa = _old[0] + if _old[1]: _existing_ra = _old[1] + if _tag_absent and _old[2]: + _existing_tag = _old[2] except: pass @@ -1112,10 +1192,10 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool avg_price, decision_timestamp, note, quality_check, quality_checked_at, quality_issues_json, position_advice, signal_factors_json, time_horizon, decision_type, - full_analysis, reassessed_at) + full_analysis, reassessed_at, tag) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now','localtime'), - ?,?,?,?,?,?,?,?,?,?,?,?) + ?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( code, name, data.get('version', 1), data.get('price'), data.get('cost'), @@ -1141,6 +1221,7 @@ def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool # 保留full_analysis和reassessed_at _existing_fa, _existing_ra, + _existing_tag, )) conn.commit() return True, f"策略 {code} 已写入" diff --git a/prompt_manager/registry.py b/prompt_manager/registry.py index fd7eaba3..ca450a95 100644 --- a/prompt_manager/registry.py +++ b/prompt_manager/registry.py @@ -9,7 +9,7 @@ from typing import Optional from .models import PromptDef, PromptVersion, PROMPT_CATEGORIES # 数据文件路径 -DATA_DIR = Path("/home/hmo/projects/MoFin/data/prompts") +DATA_DIR = Path("/home/hmo/MoFin/data/prompts") REGISTRY_PATH = DATA_DIR / "registry.json" VERSIONS_DIR = DATA_DIR / "versions" diff --git a/scripts/mo_data.py b/scripts/mo_data.py index db6c7e01..cb168a13 100644 --- a/scripts/mo_data.py +++ b/scripts/mo_data.py @@ -82,7 +82,7 @@ def read_decisions(): "created_at, updated_at, " "avg_price, decision_timestamp, note, quality_check, " "quality_checked_at, quality_issues_json, position_advice, " - "signal_factors_json, time_horizon, decision_type " + "signal_factors_json, time_horizon, decision_type, tag " "FROM holding_strategies WHERE status IN ('active','updated') " "ORDER BY code" ).fetchall() diff --git a/server.py b/server.py index 99b2648b..451c4cc5 100644 --- a/server.py +++ b/server.py @@ -138,35 +138,206 @@ def index(): @app.route("/api/watch") def get_watch(): - """盯盘:当前有操作建议的持仓+自选""" + """盯盘:所有有效策略(持仓+自选),服务端排序""" import sqlite3 conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") conn.row_factory = sqlite3.Row - # 持仓:所有买卖操作都进盯盘;自选:只有买入/加仓信号(没持仓不可能卖) + # 1) 所有 active 持仓策略 + 自选策略 rows = conn.execute(""" - SELECT hs.code, hs.name, hs.decision_type, lp.price, lp.change_pct, + SELECT hs.code, hs.name, hs.decision_type, hs.timing_signal, + hs.action, hs.position_advice, hs.tag, hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit, - hs.rr_ratio, hs.timing_signal, hs.position_advice, hs.action, - hs.full_analysis + hs.rr_ratio, hs.full_analysis, hs.reassessed_at, + lp.price, lp.change_pct, + h.shares, h.position_pct FROM holding_strategies hs LEFT JOIN live_prices lp ON hs.code = lp.code + LEFT JOIN holdings h ON hs.code = h.code AND h.is_active = 1 WHERE hs.status='active' - AND ( - (hs.decision_type='持仓策略' AND hs.timing_signal IN ('买入','可买入','可加仓','卖出','止盈')) - OR - (hs.decision_type='自选策略' AND hs.timing_signal IN ('买入','可买入','可加仓')) - ) - ORDER BY - CASE hs.timing_signal - WHEN '买入' THEN 1 WHEN '可买入' THEN 2 WHEN '可加仓' THEN 3 - WHEN '止盈' THEN 4 WHEN '卖出' THEN 5 - ELSE 9 - END, - lp.change_pct DESC + AND hs.decision_type IN ('持仓策略','自选策略') """).fetchall() conn.close() - return json.dumps({"stocks": [dict(r) for r in rows], "count": len(rows)}, - ensure_ascii=False) + + # 信号强度排序映射 + signal_rank = { + '买入': 1, '可买入': 2, '可加仓': 3, '止盈': 4, '卖出': 5, + '关注': 6, '观望': 7, '持有': 8, '弱势持有': 9, '信号不充分': 10, + } + + results = [] + for r in rows: + d = dict(r) + # 分类 sort_group + tag = d.get('tag') or '' + if tag in ('current_recommend', 'active_manual'): + d['sort_group'] = 0 # 推荐 + elif d['decision_type'] == '持仓策略': + d['sort_group'] = 1 # 持仓 + else: + d['sort_group'] = 2 # 自选 + + sig = d.get('timing_signal') or '' + d['_sig_rank'] = signal_rank.get(sig, 99) + + # 持仓仓位(用于持仓组内排序) + d['_pos'] = d.get('position_pct') or 0 + d['_rr'] = d.get('rr_ratio') or 0 + + # 截断 full_analysis + fa = d.get('full_analysis') or '' + if len(fa) > 4000: + fa = fa[:4000] + '\n...(已截断)' + d['full_analysis'] = fa + + results.append(d) + + # 排序:group → signal_rank → group-internal (持仓按position_pct desc, 自选按rr desc) + def skey(x): + g = x['sort_group'] + sr = x['_sig_rank'] + # 同 signal 时持仓按仓位、自选按RR + inner = x['_pos'] if g == 1 else x['_rr'] + return (g, sr, -inner, x.get('code', '')) + + results.sort(key=skey) + + # 移除辅助排序键 + for d in results: + d.pop('_sig_rank', None) + d.pop('_pos', None) + d.pop('_rr', None) + + return json.dumps({"stocks": results, "count": len(results)}, ensure_ascii=False) + + +@app.route("/api/strategy_history/") +def api_strategy_history(code): + """某只股票最近 N 条策略记录(strategy_history 表)""" + limit = min(int(request.args.get('limit', 3)), 20) + import sqlite3 + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + conn.row_factory = sqlite3.Row + try: + rows = conn.execute(""" + SELECT id, code, name, decision_type, strategy_type, + full_analysis, action, timing_signal, + entry_low, entry_high, stop_loss, take_profit, + position_advice, rr_ratio, version, source_trigger, + reassessed_at, snapshotted_at + FROM strategy_history + WHERE code=? + ORDER BY snapshotted_at DESC + LIMIT ? + """, (code, limit)).fetchall() + history = [dict(r) for r in rows] + except Exception: + # 表不存在或查询失败 → 降级为当前 holding_strategies 行 + history = [] + try: + cur = conn.execute(""" + SELECT code, name, decision_type, timing_signal, action, + entry_low, entry_high, stop_loss, take_profit, + rr_ratio, position_advice, full_analysis, + reassessed_at + FROM holding_strategies + WHERE code=? AND status='active' + """, (code,)).fetchone() + if cur: + d = dict(cur) + d['version'] = 'current' + d['snapshotted_at'] = d.get('reassessed_at', '') + d['is_current'] = True + history = [d] + except Exception: + history = [] + finally: + conn.close() + + return jsonify({"code": code, "count": len(history), "history": history}) + + +_CRON_ID_MAP_CACHE = {"ts": 0, "map": {}} + +def _cron_name_to_id(): + """从两个 profile 的 hermes jobs.json 构建 name->id 映射(60s 缓存)。""" + import time as _t, glob as _g, json as _j + now = _t.time() + if now - _CRON_ID_MAP_CACHE["ts"] < 60: + return _CRON_ID_MAP_CACHE["map"] + m = {} + for pj in _g.glob("/home/hmo/.hermes/profiles/*/cron/jobs.json"): + try: + with open(pj, encoding="utf-8") as f: + jobs = _j.load(f) + jobs = jobs if isinstance(jobs, list) else jobs.get("jobs", []) + for j in jobs: + jid, jname = str(j.get("id", "")), str(j.get("name", "")) + if jid: + m[jid] = jid + if jname and jid: + m[jname] = jid + except Exception: + pass + _CRON_ID_MAP_CACHE["ts"] = now + _CRON_ID_MAP_CACHE["map"] = m + return m + + +@app.route("/api/reports") +def api_reports(): + """历史报告列表,支持 ?cron=&script=<脚本名>&limit=N 过滤 + + 匹配链:pipeline名→jobs.json解析为job id→文件名前缀 cron_{id}_ + → pipeline名子串 → 脚本名(去.py)子串 → 报告title子串。 + """ + reports_dir = DATA_DIR / "reports" + reports = [] + if reports_dir.exists(): + cron_key = (request.args.get("cron") or "").strip() + script_key = (request.args.get("script") or "").strip() + if script_key.endswith(".py"): + script_key = script_key[:-3] + limit = min(int(request.args.get("limit", 100)), 200) + + job_id = "" + if cron_key: + job_id = _cron_name_to_id().get(cron_key, "") + + for f in sorted(reports_dir.iterdir(), reverse=True): + if f.suffix != ".json": + continue + if cron_key or script_key: + stem = f.stem + hit = False + if job_id and stem.startswith(f"cron_{job_id}_"): + hit = True + elif cron_key and cron_key in stem: + hit = True + elif script_key and script_key in stem: + hit = True + if not hit: + continue + data = _load_json(f) + # 最后兜底:pipeline名出现在报告标题里也算匹配 + if (cron_key or script_key) and cron_key: + title = str(data.get("title", "")) + stem = f.stem + if not (job_id and stem.startswith(f"cron_{job_id}_")) \ + and cron_key not in stem \ + and not (script_key and script_key in stem) \ + and cron_key not in title: + continue + reports.append({ + "id": f.stem, + "title": data.get("title", f.stem), + "type": data.get("type", "未知"), + "created_at": data.get("created_at", ""), + "summary": data.get("summary", ""), + "cron": data.get("cron") or data.get("job") or "", + }) + if len(reports) >= limit: + break + return jsonify(reports) @app.route("/api/portfolio") def api_portfolio(): @@ -249,25 +420,6 @@ def api_overview(): return jsonify({"error": "数据库查询失败"}), 500 -@app.route("/api/reports") -def api_reports(): - """历史报告列表""" - reports_dir = DATA_DIR / "reports" - reports = [] - if reports_dir.exists(): - for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: - if f.suffix == ".json": - data = _load_json(f) - reports.append({ - "id": f.stem, - "title": data.get("title", f.stem), - "type": data.get("type", "未知"), - "created_at": data.get("created_at", ""), - "summary": data.get("summary", ""), - }) - return jsonify(reports) - - @app.route("/api/report/") def api_report(report_id): """单个报告详情""" diff --git a/static/index.html b/static/index.html index fba53f7c..9cab2afc 100644 --- a/static/index.html +++ b/static/index.html @@ -58,15 +58,13 @@ body { background: #0f0f13; color: #e2e8f0; } - - - + + - 📸 上传 @@ -74,8 +72,6 @@ body { background: #0f0f13; color: #e2e8f0; }
- - @@ -95,10 +91,24 @@ body { background: #0f0f13; color: #e2e8f0; } + + + + @@ -212,8 +222,6 @@ function renderTab(name) { if (name === 'overview') renderOverview(); else if (name === 'holdings') renderHoldings(); else if (name === 'watchlist') renderWatchlist(); - else if (name === 'reports') renderReports(); - else if (name === 'decisions') renderDecisions(); else if (name === 'evaluation') renderEvaluation(); else if (name === 'prompts') renderPrompts(); else if (name === 'market') renderMarket(); @@ -437,49 +445,6 @@ function renderWatchlist() { `; } -// ── Reports Tab ── -function renderReports() { - const el = document.getElementById('tab-reports'); - if (!reportsData.length) { - el.innerHTML = '
暂无报告
'; - return; - } - el.innerHTML = ` -
- 📋 报告列表 - ?§ -
-
- ${reportsData.map(r => ` -
-
- ${r.type||'其他'} - ${r.created_at?.slice(5,16)||''} -
-
${r.title||r.id}
-
${r.summary||''}
-
- `).join('')} -
- `; -} - -async function viewReport(id) { - const d = await fetchJSON(`/api/report/${id}`); - if (d.error) return; - const el = document.getElementById('tab-reports'); - el.innerHTML = ` -
-
- - ${d.created_at||''} -
-
${d.title||'报告'}
-
${d.content||d.summary||'(无内容)'}
-
- `; -} - // ── Market Tab ── let marketRefreshInterval = null; @@ -586,356 +551,6 @@ function renderMarket() { marketRefreshInterval = setInterval(refreshMarketData, 30000); } -// ── Decisions Tab ── -let decisionsIsComposing = false; -let decisionsSearchTerm = ''; -let decisionsRecommendOnly = false; -let decisionsTagFilter = ''; // '' = all tags - -function decisionsFilter() { - if (decisionsIsComposing) return; // 跳过IME输入法组合中的中间值 - const el = document.getElementById('decisionsSearch'); - if (!el) return; - decisionsSearchTerm = el.value.trim().toLowerCase(); - renderDecisionsFiltered(); -} - -function toggleRecommendFilter() { - decisionsRecommendOnly = !decisionsRecommendOnly; - const btn = document.getElementById('recommendFilterBtn'); - if (btn) { - btn.classList.toggle('bg-yellow-900/30', decisionsRecommendOnly); - btn.classList.toggle('border-yellow-400', decisionsRecommendOnly); - btn.textContent = decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐'; - } - renderDecisionsFiltered(); -} - -function decisionsTagFilterSet(tag) { - decisionsTagFilter = (tag === decisionsTagFilter) ? '' : tag; - renderDecisionsFiltered(); - // 重新聚焦搜索框 - document.getElementById('decisionsSearch')?.focus(); -} - -/** 计算"距操作区距离"(越小越近),百分比 */ -function calcProximity(d) { - const price = d.price || 0; - const sl = d.stop_loss || 0; - const tp = d.take_profit || 0; - const el = d.entry_low || 0; - const eh = d.entry_high || 0; - if (!price) return 999; - - let minDist = 999; - - // 距止损的距离(百分比) - if (sl > 0) { - const dist = Math.abs((price - sl) / sl) * 100; - minDist = Math.min(minDist, dist); - } - - // 距止盈的距离(百分比) - if (tp > 0) { - const dist = Math.abs((tp - price) / price) * 100; - minDist = Math.min(minDist, dist); - } - - // 距买入区的距离 - if (el > 0 && eh > 0) { - if (price >= el && price <= eh) { - // 在买入区内,距边界距离 - const distToLow = (price - el) / (eh - el) * 100; - const distToHigh = (eh - price) / (eh - el) * 100; - minDist = Math.min(minDist, Math.min(distToLow, distToHigh)); - } else if (price < el) { - const dist = (el - price) / el * 100; - minDist = Math.min(minDist, dist); - } else { - const dist = (price - eh) / eh * 100; - minDist = Math.min(minDist, dist); - } - } - - return minDist; -} - -function renderDecisionsFiltered() { - const el = document.getElementById('tab-decisions'); - const items = decisionsCache; - const term = decisionsSearchTerm; - const recOnly = decisionsRecommendOnly; - const tagFilter = decisionsTagFilter; - - // 搜索过滤 - let filtered = term ? items.filter(x => { - const name = (x.name || '').toLowerCase(); - const code = (x.code || '').toLowerCase(); - const action = (x._raw_action || x.action || '').toLowerCase(); - const note = (x.updated_reason || x.note || '').toLowerCase(); - return name.includes(term) || code.includes(term) || action.includes(term) || note.includes(term); - }) : items; - - // 当前推荐筛选(点击⭐按钮时启用) - if (recOnly) { - filtered = filtered.filter(x => x.tag === 'current_recommend' || x.tag === 'active_manual'); - } - - // 标签过滤 - if (tagFilter) { - filtered = filtered.filter(x => x.tag === tagFilter); - } - - // 只显示有策略的条目 - const withStrategy = filtered.filter(x => { - const tg = x.trigger || {}; - return !!(x.stop_loss || tg.stop_loss || x.take_profit || tg.take_profit || x.entry_low || tg.entry_zone || x._raw_action); - }); - - // 给每个条目算距离 - withStrategy.forEach(x => { x._proximity = calcProximity(x); }); - - // 分组(按优先级从高到低) - // 1. 执行中(已建仓+部分退出,按距操作区距离排序) - const executing = withStrategy.filter(x => x.execution?.status === 'executing' || x.execution?.status === 'partial_exit') - .sort((a, b) => a._proximity - b._proximity); - - // 2. 接近操作区(距操作区 < 5%,不论状态) - const proxThreshold = 5; - const proxKeys = new Set(executing.map(x => x.code)); - const nearZone = withStrategy.filter(x => - !proxKeys.has(x.code) && x._proximity < proxThreshold && - x.execution?.status !== 'observing' - ).sort((a, b) => a._proximity - b._proximity); - nearZone.forEach(x => proxKeys.add(x.code)); - - // 3. 观察中 - const observing = withStrategy.filter(x => - !proxKeys.has(x.code) && x.execution?.status === 'observing' - ).sort((a, b) => a._proximity - b._proximity); - observing.forEach(x => proxKeys.add(x.code)); - - // 4. 其余 - const others = withStrategy.filter(x => !proxKeys.has(x.code)) - .sort((a, b) => a._proximity - b._proximity); - - // 统计标签 - const tagCounts = {}; - withStrategy.forEach(x => { - const t = x.tag || ''; - if (t) tagCounts[t] = (tagCounts[t] || 0) + 1; - }); - const hasTags = Object.keys(tagCounts).length > 0; - - const matched = term ? `(搜索"${term}" 匹配 ${filtered.length} 只)` : ''; - const tagPills = hasTags ? ` -
- - ${Object.entries(tagCounts).map(([tag, count]) => { - const label = tag === 'active_manual' ? '当前推荐操作' : - tag === 'current_recommend' ? '知微推荐' : tag; - const active = tagFilter === tag; - const colors = (tag === 'active_manual' || tag === 'current_recommend') - ? (active ? 'bg-amber-500 text-white' : 'bg-amber-900/40 text-amber-300 hover:bg-amber-800/50') - : (active ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'); - return ``; - }).join('')} -
- ` : ''; - - el.innerHTML = ` - ${tagPills} -
- 策略决策库 ?§ - · ${withStrategy.length} 只 ${matched} - - ${executing.length} 执行中 - ${nearZone.length} 接近操作 - ${observing.length} 观察 - ${others.length} 其他 -
-
- - 🔍 - ${term ? `` : ''} -
- - ${executing.length > 0 ? ` -
-
- ▶ 正在执行 - 已建仓 · 距操作区最近优先 -
- ${renderDecCards(executing, true)} -
- ` : ''} - - ${nearZone.length > 0 ? ` -
-
- ⚡ 接近操作区 - 距关键位<5%,重点关注 -
- ${renderDecCards(nearZone, true)} -
- ` : ''} - - ${observing.length > 0 ? ` -
-
- ◐ 观察中 - 自选关注,等待入场时机 -
- ${renderDecCards(observing, false)} -
- ` : ''} - - ${others.length > 0 ? ` -
-
- ○ 其他 - 远离操作区 -
- ${renderDecCards(others, false)} -
- ` : ''} - - ${withStrategy.length === 0 ? `
${term ? '未匹配到结果' : '暂无带策略的条目'}
` : ''} - `; - - if (term) document.getElementById('decisionsSearch')?.focus(); -} - -async function renderDecisions() { - const d = await fetchJSON('/api/decisions'); - decisionsCache = d.decisions || []; - renderDecisionsFiltered(); -} - -function renderDecCards(items, showExecution) { - return `
${items.map(d => { - const exec = d.execution || {}; - const analysis = d.analysis || {}; - const timeline = d.advice_timeline || d.changelog || []; - - // 策略区(兼容新旧字段名) - const tg = d.trigger || {}; - const stopLoss = d.stop_loss || tg.stop_loss || ''; - const takeProfit = d.take_profit || tg.take_profit || ''; - const entryLow = d.entry_low || 0; - const entryHigh = d.entry_high || 0; - const techSnap = d.tech_snapshot || analysis.tech_basis || ''; - const timingSignal = d.timing_signal || analysis.timing_signal || ''; - const action = d.action || ''; - const hasTrigger = !!(stopLoss || (entryLow && entryHigh) || takeProfit); - - // 执行区(实际) - const execStatus = exec.status || 'none'; - const execPnl = exec.pnl_pct || ''; - const execPrice = exec.entry_price || ''; - const execShares = exec.shares || ''; - const execNotes = exec.notes || ''; - - return `
-
-
- ${d.name} - ${d.code} - ${d.type||'—'} - ${execStatus === 'executing' ? '已执行' : ''} - ${execStatus === 'partial_exit' ? '部分退出' : ''} - ${execStatus === 'observing' ? '观察中' : ''} - ${d.tag === 'active_manual' || d.tag === 'current_recommend' ? (() => { - let label = '⭐ '; - if (d.tag === 'active_manual') { - if (execStatus === 'executing') label += '执行中 持有'; - else if (execStatus === 'partial_exit') label += '执行中 部分退出'; - else label += '待买入'; - } else { - if (execStatus === 'partial_exit') label += '部分退出 剩余半仓'; - else if (execStatus === 'observing') label += '推荐买入'; - else label += '推荐操作'; - } - return `${label}`; - })() : ''} -
- ${d.timestamp?.slice(5,16)||''} -
- - -
- -
-
📐 策略理论
- ${hasTrigger ? ` -
- ${stopLoss ? `
止损 ${stopLoss}
` : ''} - ${entryLow ? `
买入区 ${entryLow}~${entryHigh}
` : tg.entry_zone ? `
买入区 ${tg.entry_zone}
` : ''} - ${takeProfit ? `
止盈 ${takeProfit}
` : ''} -
- ${action ? `
${action}
` : ''} - ${analysis.reasoning ? `
${analysis.reasoning.slice(0,100)}
` : ''} - ${timingSignal && !timingSignal.includes('neutral') ? `
⏱ ${timingSignal}
` : ''} - ` : `
无策略
`} -
- - -
-
⚡ 实际执行
- ${execStatus === 'executing' || execStatus === 'partial_exit' ? ` -
-
建仓价¥${execPrice||'?'}
-
持仓${execShares||'?'}股
-
盈亏${execPnl||'?'}
- ${exec.remaining_shares ? `
剩余${exec.remaining_shares}股
` : ''} - ${exec.realized_gain ? `
已实现+${exec.realized_gain}
` : ''} - ${exec.notes ? `
${exec.notes}
` : ''} -
- ` : execStatus === 'observing' ? ` -
观察中,等待入场时机
-
${execNotes||'关注回调至买入区'}
- ` : ` -
尚未执行
- `} -
-
- - - ${timeline.length ? ` -
-
📜 建议记录 (${timeline.length})
-
- ${timeline.slice(-3).map(a => - `
- ${a.date?.slice(5,16)||''} - ${a.direction||''} - ${a.summary?.slice(0,40)||''} - ${a.status === 'pending' ? '' : a.status === 'confirmed' ? '' : a.status === 'ignored' ? '' : ''} -
` - ).join('')} -
-
- ` : ''} - - ${d.updated_reason ? `
📝 ${d.updated_reason}
` : ''} -
`; - }).join('')}
`; -} - - // ── Evaluation Tab ── function renderEvaluation() { const el = document.getElementById('tab-evaluation'); @@ -1127,7 +742,11 @@ function renderPrompts() { // 提示词列表 if (prompts.length === 0) { - html += '
暂无提示词记录,请先初始化注册表。
'; + html += '
' + + '
⚠️ 提示词注册表未初始化
' + + '
请在服务器运行 prompt_manager/init_registry.py 初始化注册表。
' + + '
路径: /home/hmo/MoFin/prompt_manager/init_registry.py
' + + '
'; } else { html += '
'; html += ''; @@ -1161,7 +780,11 @@ function renderPrompts() { // 保存数据供版本弹窗使用 window._promptData = data; }).catch(err => { - el.innerHTML = `
加载失败: ${err.message}
`; + el.innerHTML = '
' + + '
⚠️ 提示词注册表加载失败
' + + '
错误: ' + (err.message || 'unknown') + '
' + + '
请确认服务器已运行 prompt_manager/init_registry.py 初始化注册表。
' + + '
'; }); } @@ -1710,68 +1333,240 @@ async function refreshHealth() { // ── 盯盘 ── let watchTimer = null; + +// 信号徽标颜色 +function sigBadgeClass(sig) { + if (!sig) return 'bg-slate-800 text-slate-400'; + const buy = ['买入','可买入','可加仓']; + const sell = ['卖出','止盈']; + if (buy.some(k => sig.includes(k))) return 'bg-green-900/50 text-green-300'; + if (sell.some(k => sig.includes(k))) return 'bg-red-900/50 text-red-300'; + return 'bg-yellow-900/50 text-yellow-300'; +} + +// 格式化策略摘要(2行截断) +function fmtStrategy(s) { + const action = s.action || ''; + const advice = s.position_advice || ''; + const parts = []; + if (action) parts.push(action); + if (advice) parts.push(advice); + if (parts.length === 0) return '—'; + return parts.join(' · '); +} + +// 格式化完整策略(modal 内用) +function fmtFullStrategy(s) { + const lines = []; + if (s.action) lines.push('操作: ' + s.action); + if (s.position_advice) lines.push('仓位建议: ' + s.position_advice); + if (s.entry_low || s.entry_high) lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—')); + if (s.stop_loss || s.take_profit) lines.push('止损: ' + (s.stop_loss||'—') + ' / 止盈: ' + (s.take_profit||'—')); + if (s.rr_ratio) lines.push('RR: ' + s.rr_ratio.toFixed(2)); + if (s.timing_signal) lines.push('信号: ' + s.timing_signal); + if (s.full_analysis) lines.push('\n分析: ' + s.full_analysis); + return lines.join('\n'); +} + async function renderWatch() { const el = document.getElementById('watchContent'); if (!el) return; try { - // 清除旧定时器,切走时停 if (watchTimer) clearInterval(watchTimer); const data = await fetchJSON('/api/watch'); const stocks = data.stocks || []; document.getElementById('watchCount').textContent = stocks.length; document.getElementById('watchRefreshTime').textContent = new Date().toLocaleTimeString(); + if (stocks.length === 0) { - el.innerHTML = '
暂无有效操作建议
'; + el.innerHTML = '
暂无策略数据
'; return; } - let html = ''; - for (const s of stocks) { - const p = s.price || 0; - const cp = s.change_pct || 0; - const sig = s.timing_signal || ''; - const isBuy = sig.includes('买入') || sig.includes('加仓'); - const isSell = sig.includes('卖出') || sig.includes('止盈'); - const sigColor = isBuy ? 'bg-green-900/50 text-green-300' : isSell ? 'bg-red-900/50 text-red-300' : 'bg-yellow-900/50 text-yellow-300'; - const chgColor = cp >= 0 ? 'text-red-400' : 'text-green-400'; - const el_ = s.entry_low || 0; - const eh_ = s.entry_high || 0; - const sl_ = s.stop_loss || 0; - const tp_ = s.take_profit || 0; - const rr = s.rr_ratio || 0; - const inZone = (p && el_ && eh_ && p >= el_ && p <= eh_) ? '✅区内' : (p && eh_ && p > eh_) ? '⬆️超出' : '⬇️低于'; - html += ` -
-
-
- ${s.code} - ${s.name || ''} - ${sig} - ${s.decision_type==='持仓策略'?'💼':'⭐'} -
-
- ${p.toFixed(2)} - ${cp >= 0 ? '+' : ''}${cp.toFixed(2)}% - 区${el_.toFixed(1)}~${eh_.toFixed(1)} - ${inZone} - RR ${rr.toFixed(2)} - 损${sl_.toFixed(1)} 盈${tp_.toFixed(1)} -
-
-
-
仓位
-
${s.position_advice || '—'}
-
-
`; + + // 分组渲染 + const groups = [ + { label: '🔥 重点推荐', g: 0 }, + { label: '💼 持仓策略', g: 1 }, + { label: '⭐ 自选策略', g: 2 }, + ]; + + let html = '
提示词分类当前版本版本数关联策略成功率操作
'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + for (const grp of groups) { + const items = stocks.filter(s => s.sort_group === grp.g); + if (items.length === 0) continue; + + // 分组标题行 + html += '' + + '' + + ''; + + for (const s of items) { + const p = s.price || 0; + const cp = s.change_pct || 0; + const sig = s.timing_signal || ''; + const isRec = s.sort_group === 0; // 推荐行 + + const sigCls = sigBadgeClass(sig); + const chgColor = cp >= 0 ? 'text-red-400' : 'text-green-400'; + const el_ = s.entry_low || 0; + const eh_ = s.entry_high || 0; + const sl_ = s.stop_loss || 0; + const tp_ = s.take_profit || 0; + const rr = s.rr_ratio || 0; + const shares = s.shares || 0; + const posPct = s.position_pct || 0; + + const buyZone = (el_ && eh_) ? el_.toFixed(1) + '~' + eh_.toFixed(1) : '—'; + const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—'; + const posDisplay = posPct ? posPct.toFixed(1) + '%' : '—'; + + // 当前操作策略摘要 + const strat = fmtStrategy(s); + + // 推荐行高亮 + const rowCls = isRec ? 'bg-amber-900/15 border-l-2 border-l-amber-400' : 'border-b border-slate-800/30 hover:bg-slate-800/20'; + const recBadge = isRec ? '🔥推荐' : ''; + + // 推荐行策略文本加粗醒目 + const stratCls = isRec ? 'font-semibold text-amber-200' : 'text-slate-300'; + + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } } + + html += '
股票现价涨跌%信号买入区间止损/止盈RR仓位当前操作策略操作策略
' + grp.label + ' (' + items.length + ')
' + s.name + '
' + s.code + '' + recBadge + '
' + (p ? p.toFixed(2) : '—') + '' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%' + (sig || '—') + '' + buyZone + '' + sltp + '' + (rr ? rr.toFixed(2) : '—') + '' + posDisplay + '' + strat + '
'; + // 更新时间 + html += '
' + (data.updated_at ? '数据: ' + data.updated_at : '') + '
'; + el.innerHTML = html; // 自动刷新:每30秒 watchTimer = setInterval(renderWatch, 30000); } catch(e) { - el.innerHTML = `
加载失败: ${e.message}
`; + el.innerHTML = '
加载失败: ' + e.message + '
'; watchTimer = setInterval(renderWatch, 60000); } } +// ── 操作策略历史 Modal ── +async function openStrategyHistory(code) { + const modal = document.getElementById('strategyModal'); + modal.classList.remove('hidden'); + modal.classList.add('flex'); + document.getElementById('strategyModalTitle').textContent = '加载中...'; + document.getElementById('strategyModalMeta').textContent = ''; + document.getElementById('strategyModalBody').innerHTML = '
正在获取策略历史...
'; + + try { + const d = await fetchJSON('/api/strategy_history/' + code + '?limit=3'); + const history = d.history || []; + if (history.length === 0) { + document.getElementById('strategyModalTitle').textContent = '操作策略历史'; + document.getElementById('strategyModalMeta').textContent = code + ' · 无历史记录'; + document.getElementById('strategyModalBody').innerHTML = '
暂无策略历史记录
'; + return; + } + // 找出股票名称 + const name = history[0].name || code; + document.getElementById('strategyModalTitle').textContent = name + ' · ' + code; + document.getElementById('strategyModalMeta').textContent = '最近 ' + history.length + ' 条策略记录'; + + let body = ''; + // 最新一条默认展开,其余可折叠 + for (let i = 0; i < history.length; i++) { + const h = history[i]; + const isLatest = i === 0; + const collapsed = !isLatest ? ' hidden' : ''; + const toggleId = 'strat-' + i; + + // 策略头信息 + const headInfo = []; + if (h.snapshotted_at) headInfo.push('快照: ' + h.snapshotted_at.slice(0, 16)); + if (h.reassessed_at) headInfo.push('重评: ' + h.reassessed_at.slice(0, 16)); + if (h.version) headInfo.push('版本: ' + h.version); + if (h.is_current) headInfo.push('⚡ 当前策略'); + + const action = h.action || ''; + const timing = h.timing_signal || ''; + const sigCls = sigBadgeClass(timing); + + body += '
' + + '
' + + '
' + + '' + (action || '—') + '' + + '' + (timing || '—') + '' + + '
' + headInfo.join(' · ') + '
' + + '
' + + (isLatest ? '' : '') + + '
'; + + // 策略参数 + const params = []; + if (h.entry_low || h.entry_high) params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—')); + if (h.stop_loss) params.push('止损: ¥' + h.stop_loss); + if (h.take_profit) params.push('止盈: ¥' + h.take_profit); + if (h.rr_ratio) params.push('RR: ' + h.rr_ratio.toFixed(2)); + if (h.position_advice) params.push('仓位: ' + h.position_advice); + + if (params.length > 0) { + body += '
' + params.join(' · ') + '
'; + } + + // 完整分析(折叠) + const fa = h.full_analysis || ''; + if (fa) { + body += '
' + + '📋 完整分析' + + '
' + fa.replace(/' +
+          '
'; + } + + body += '
'; + } + + document.getElementById('strategyModalBody').innerHTML = body; + } catch(e) { + document.getElementById('strategyModalTitle').textContent = '操作策略历史'; + document.getElementById('strategyModalMeta').textContent = code; + document.getElementById('strategyModalBody').innerHTML = '
加载失败: ' + e.message + '
'; + } +} + +function closeStrategyModal() { + document.getElementById('strategyModal').classList.add('hidden'); + document.getElementById('strategyModal').classList.remove('flex'); +} + +function toggleStrat(id) { + const el = document.getElementById(id); + if (el) { + el.classList.toggle('hidden'); + } +} + // ── 开发原则 Tab(仿 AgentsMeeting: G规范/K测试/F健康/H需求)── let _princLoaded = {}; function renderPrinciples() { diff --git a/static/mofin_health.html b/static/mofin_health.html index 866aa75d..26f17cf2 100644 --- a/static/mofin_health.html +++ b/static/mofin_health.html @@ -42,6 +42,22 @@ td.wrap{white-space:normal;font-size:11px;max-width:200px} .cron-tag.warn{background:#3a2a1a;color:#d29922;border:1px solid #d29922} .cron-tag.error{background:#3a1a1a;color:#f85149;border:1px solid #f85149} .cron-tag.new{background:#1a1a3a;color:#58a6ff;border:1px solid #58a6ff} +/* 最后十次 报告按钮 & 弹窗 */ +.pipe-reports-btn{background:#161b22;color:#58a6ff;border:1px solid #30363d;padding:2px 8px;border-radius:3px;font-size:11px;cursor:pointer;transition:all .15s} +.pipe-reports-btn:hover{background:#21262d;border-color:#58a6ff} +.rpt-modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center} +.rpt-modal{background:#161b22;border:1px solid #30363d;border-radius:8px;max-width:800px;width:90%;max-height:80vh;overflow-y:auto;padding:20px;position:relative} +.rpt-modal h3{color:#58a6ff;margin-bottom:12px;font-size:16px;display:flex;justify-content:space-between;align-items:center} +.rpt-modal .close-btn{cursor:pointer;color:#8b949e;font-size:20px;background:none;border:none} +.rpt-modal .close-btn:hover{color:#f85149} +.rpt-list{margin-bottom:12px} +.rpt-item{padding:8px 10px;border:1px solid #21262d;border-radius:4px;margin-bottom:4px;cursor:pointer;transition:all .15s} +.rpt-item:hover{background:#21262d;border-color:#30363d} +.rpt-item .rpt-title{font-size:13px;color:#c9d1d9;font-weight:500} +.rpt-item .rpt-meta{font-size:11px;color:#8b949e;margin-top:2px} +.rpt-detail{font-size:12px;color:#c9d1d9;white-space:pre-wrap;line-height:1.6;background:#0d1117;padding:12px;border-radius:4px;max-height:50vh;overflow-y:auto;border:1px solid #21262d} +.rpt-back{color:#58a6ff;cursor:pointer;font-size:12px;margin-bottom:8px;display:inline-block} +.rpt-back:hover{color:#79c0ff}

📊 MoFin 系统健康监控

@@ -121,6 +137,7 @@ function renderSelfCheck(sc) { } function switchTab(idx) { + closeReportModal(); document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',i==idx)); document.querySelectorAll('.panel').forEach((p,i)=>p.classList.toggle('active',i==idx)); } @@ -297,18 +314,22 @@ function renderPipelineTable(pipelines) { html += ''; html += ''; html += ''; - html += ''; - pipelines.forEach(p => { + html += '
名称来源脚本/LLM类型调度状态最后运行
'; + pipelines.forEach((p, idx) => { const tagCls = p.status==='ok'?'ok':p.status==='error'?'error':'warn'; const statusDisplay = p.last_run ? p.status : '待首次运行'; const profileBadge = p.profile==='position-analyst' ? '📋' : '📦'; + // cron 匹配 key:pipeline 中文名(服务端经 jobs.json 解析为 job id)+ 脚本名兜底 + const encName = encodeURIComponent(p.name || ''); + const encScript = encodeURIComponent(p.script || ''); html += ``; html += ``; html += ``; html += ``; html += ``; html += ``; - html += ``; + html += ``; + html += ``; }); html += '
名称来源脚本/LLM类型调度状态最后运行最后十次
${p.name||p.script||'LLM'}${profileBadge} ${p.profile||'?'}${p.script||'LLM'}${p.type||'cron'}${p.schedule||'-'}${statusDisplay}${p.last_run||'-'}
${p.last_run||'-'}
'; return html; @@ -386,5 +407,75 @@ function loadData() { loadData(); setInterval(loadData, 60000); + +// ── 最后十次 报告 Modal ── +function closeReportModal() { + const el = document.getElementById('reportModal'); + if (el) el.remove(); +} + +function openLastReports(btn) { + const cronKey = btn.getAttribute('data-cron'); + const scriptKey = btn.getAttribute('data-script') || ''; + const pipelineName = btn.getAttribute('data-name') || decodeURIComponent(cronKey); + // 创建/替换 modal + closeReportModal(); + const overlay = document.createElement('div'); + overlay.id = 'reportModal'; + overlay.className = 'rpt-modal-overlay'; + overlay.onclick = function(e){ if(e.target === this) closeReportModal(); }; + overlay.innerHTML = ` +
+

📋 ${pipelineName} · 最后十次报告

+
加载中...
+
`; + document.body.appendChild(overlay); + // 获取报告列表(cron=pipeline名 + script=脚本名,服务端多路匹配) + fetch('/api/reports?cron=' + cronKey + '&script=' + scriptKey + '&limit=10') + .then(r => r.json()) + .then(reports => { + const list = document.getElementById('reportList'); + if (!Array.isArray(reports) || reports.length === 0) { + list.innerHTML = '
暂无报告记录
'; + return; + } + list.innerHTML = reports.map(r => ` +
+
${r.title || r.id}
+
${r.type || '未知'} · ${r.created_at ? r.created_at.slice(0,16) : ''}${r.summary ? ' · ' + r.summary.slice(0,60) : ''}
+
`).join(''); + }) + .catch(e => { + document.getElementById('reportList').innerHTML = '
加载失败: ' + e.message + '
'; + }); +} + +function viewReportDetail(reportId) { + const list = document.getElementById('reportList'); + list.innerHTML = ` + ← 返回列表 +
正在加载报告: ${reportId}...
+ `; + fetch('/api/report/' + reportId) + .then(r => r.json()) + .then(d => { + if (d.error) { list.innerHTML = '
报告不存在: ' + d.error + '
'; return; } + const title = d.title || reportId; + const type = d.type || '未知'; + const created = d.created_at || ''; + const content = d.content || d.summary || ''; + list.innerHTML = ` + ← 返回列表 +
+ ${title} + ${type} · ${created} +
+
${content.replace(/ + `; + }) + .catch(e => { + list.innerHTML = '
加载失败: ' + e.message + '
'; + }); +}