重评核心重构:ds-v4-pro + 原策略全文 + strategy_history + 前端三改造

后端(重评管线):
- 新增 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/<code>, 表缺失时降级当前行)
- 提示词Tab: registry.py 数据路径改回 /home/hmo/MoFin/data/prompts
  (红线: 数据只在规范数据根), 空态提示初始化命令
This commit is contained in:
hmo
2026-07-20 23:51:24 +08:00
parent 0e13b3edda
commit de9927a627
11 changed files with 1037 additions and 600 deletions
+175 -66
View File
@@ -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}跳过")
+133
View File
@@ -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,
}
+91 -20
View File
@@ -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:
+20 -15
View File
@@ -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)
+3 -3
View File
@@ -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-100s60s 必死(37只全灭那次的根因)→ 240s
# LLM 重评冷启动 20-100sdeepseek-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}: 超时240sLLM仍慢),下轮重试")
print(f"{code}: 超时480sLLM仍慢),下轮重试")
except Exception as e:
print(f"[AUTO_REASSESS FAIL] {e}")
# ----- 结束 自选股重评 -----
+88 -7
View File
@@ -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} 已写入"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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()
+190 -38
View File
@@ -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/<code>")
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=<pipeline名>&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/<report_id>")
def api_report(report_id):
"""单个报告详情"""
+241 -446
View File
@@ -58,15 +58,13 @@ body { background: #0f0f13; color: #e2e8f0; }
<button class="tab-btn active px-4 py-2 text-sm rounded-lg" data-tab="overview">📈 概览</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="holdings">💼 持仓</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="watchlist">⭐ 自选</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="reports">📋 报告</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="decisions">📝 决策库</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="evaluation">📊 评估</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="prompts">📝 提示词</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="evaluation">📊 评估</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="market">🌐 市场</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="signals">🔍 信号</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="health">🏥 健康</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="principles">📐 开发原则</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</button>
<a href="/upload" class="tab-btn px-4 py-2 text-sm rounded-lg" style="text-decoration:none;color:#fbbf24;border-color:#fbbf24">📸 上传</a>
</div>
@@ -74,8 +72,6 @@ body { background: #0f0f13; color: #e2e8f0; }
<div id="tab-overview" class="tab-content"></div>
<div id="tab-holdings" class="tab-content hidden"></div>
<div id="tab-watchlist" class="tab-content hidden"></div>
<div id="tab-reports" class="tab-content hidden"></div>
<div id="tab-decisions" class="tab-content hidden"></div>
<div id="tab-evaluation" class="tab-content hidden"></div>
<div id="tab-prompts" class="tab-content hidden"></div>
<div id="tab-market" class="tab-content hidden"></div>
@@ -95,10 +91,24 @@ body { background: #0f0f13; color: #e2e8f0; }
</div>
<div id="tab-watch" class="tab-content hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-200">👁️ 盯盘 · 有效操作建议</h2>
<h2 class="text-lg font-semibold text-slate-200">👁️ 盯盘 · 全部策略</h2>
<span class="text-xs text-slate-500"><span id="watchRefreshTime"></span> <span id="watchCount" class="text-yellow-400 font-mono">0</span></span>
</div>
<div id="watchContent" class="space-y-2"></div>
<div id="watchContent" class="card overflow-hidden"></div>
</div>
</div>
<!-- Strategy History Modal -->
<div id="strategyModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStrategyModal()">
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="strategyModalTitle" class="text-lg font-bold text-white"></h2>
<p id="strategyModalMeta" class="text-xs text-slate-500 mt-1"></p>
</div>
<button onclick="closeStrategyModal()" class="text-slate-500 hover:text-white text-xl">&times;</button>
</div>
<div id="strategyModalBody" class="space-y-3"></div>
</div>
</div>
@@ -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 = '<div class="card p-8 text-center text-slate-500">暂无报告</div>';
return;
}
el.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">📋 报告列表</span>
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('reports','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('reports','ai')">§</span></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
${reportsData.map(r => `
<div class="card p-4 hover:border-slate-600 transition" onclick="viewReport('${r.id}')">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs px-2 py-0.5 rounded-full ${r.type==='盘中'?'bg-blue-900/50 text-blue-300':r.type==='盘后'?'bg-purple-900/50 text-purple-300':'bg-slate-800 text-slate-400'}">${r.type||'其他'}</span>
<span class="text-xs text-slate-500">${r.created_at?.slice(5,16)||''}</span>
</div>
<div class="text-sm font-medium text-white">${r.title||r.id}</div>
<div class="text-xs text-slate-400 mt-1 line-clamp-2">${r.summary||''}</div>
</div>
`).join('')}
</div>
`;
}
async function viewReport(id) {
const d = await fetchJSON(`/api/report/${id}`);
if (d.error) return;
const el = document.getElementById('tab-reports');
el.innerHTML = `
<div class="card p-6">
<div class="flex items-center gap-2 mb-4">
<button onclick="renderReports()" class="text-xs text-blue-400 hover:text-blue-300">← 返回报告列表</button>
<span class="text-xs text-slate-500">${d.created_at||''}</span>
</div>
<div class="text-lg font-bold text-white mb-4">${d.title||'报告'}</div>
<div class="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">${d.content||d.summary||'(无内容)'}</div>
</div>
`;
}
// ── 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 ? `
<div class="flex gap-1 flex-wrap mb-3">
<button onclick="decisionsTagFilterSet('')"
class="text-xs px-2 py-1 rounded-full transition ${!tagFilter ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}">
全部
</button>
${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 `<button onclick="decisionsTagFilterSet('${tag}')"
class="text-xs px-2 py-1 rounded-full transition ${colors}">
${label} (${count})
</button>`;
}).join('')}
</div>
` : '';
el.innerHTML = `
${tagPills}
<div class="flex items-center gap-2 mb-3 flex-wrap">
<span class="text-sm font-semibold">策略决策库 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('decisions','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('decisions','ai')">§</span></span></span>
<span class="text-xs text-slate-500">· ${withStrategy.length}${matched}</span>
<button id="recommendFilterBtn" onclick="toggleRecommendFilter()"
class="text-xs px-2 py-1 rounded-full border transition ${decisionsRecommendOnly ? 'bg-amber-900/30 border-amber-400 text-amber-300' : 'bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700'}">
${decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐'}
</button>
<span class="text-xs text-green-400">${executing.length} 执行中</span>
<span class="text-xs text-orange-400">${nearZone.length} 接近操作</span>
<span class="text-xs text-yellow-400">${observing.length} 观察</span>
<span class="text-xs text-slate-500">${others.length} 其他</span>
</div>
<div class="relative mb-4">
<input id="decisionsSearch" type="text" placeholder="搜索股票名或代码…" value="${term}"
class="w-full px-3 py-2 pl-8 text-xs bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
oninput="if(!this._composing)decisionsFilter()"
oncompositionstart="this._composing=true"
oncompositionend="this._composing=false;decisionsFilter()" />
<span class="absolute left-2.5 top-2 text-slate-500 text-xs">🔍</span>
${term ? `<button onclick="document.getElementById('decisionsSearch').value='';decisionsSearchTerm='';renderDecisionsFiltered()" class="absolute right-2 top-2 text-slate-500 hover:text-white text-xs">&times;</button>` : ''}
</div>
${executing.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-green-400 bg-green-900/30 px-2 py-0.5 rounded-full">▶ 正在执行</span>
<span class="text-xs text-slate-500">已建仓 · 距操作区最近优先</span>
</div>
${renderDecCards(executing, true)}
</div>
` : ''}
${nearZone.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-orange-400 bg-orange-900/30 px-2 py-0.5 rounded-full">⚡ 接近操作区</span>
<span class="text-xs text-slate-500">距关键位&lt;5%,重点关注</span>
</div>
${renderDecCards(nearZone, true)}
</div>
` : ''}
${observing.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-yellow-400 bg-yellow-900/30 px-2 py-0.5 rounded-full">◐ 观察中</span>
<span class="text-xs text-slate-500">自选关注,等待入场时机</span>
</div>
${renderDecCards(observing, false)}
</div>
` : ''}
${others.length > 0 ? `
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-slate-400 bg-slate-800/50 px-2 py-0.5 rounded-full">○ 其他</span>
<span class="text-xs text-slate-500">远离操作区</span>
</div>
${renderDecCards(others, false)}
</div>
` : ''}
${withStrategy.length === 0 ? `<div class="card p-8 text-center text-slate-500">${term ? '未匹配到结果' : '暂无带策略的条目'}</div>` : ''}
`;
if (term) document.getElementById('decisionsSearch')?.focus();
}
async function renderDecisions() {
const d = await fetchJSON('/api/decisions');
decisionsCache = d.decisions || [];
renderDecisionsFiltered();
}
function renderDecCards(items, showExecution) {
return `<div class="space-y-2">${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 `<div class="card p-4">
<div class="flex items-start justify-between mb-2">
<div>
<span class="font-medium text-white">${d.name}</span>
<span class="text-xs text-slate-500 ml-2">${d.code}</span>
<span class="text-xs px-1.5 py-0.5 rounded ml-2 ${d.type?.includes('持仓')?'bg-blue-900/50 text-blue-300':'bg-purple-900/50 text-purple-300'}">${d.type||'—'}</span>
${execStatus === 'executing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-green-900/50 text-green-300 ml-1">已执行</span>' : ''}
${execStatus === 'partial_exit' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-orange-900/50 text-orange-300 ml-1">部分退出</span>' : ''}
${execStatus === 'observing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-yellow-900/50 text-yellow-300 ml-1">观察中</span>' : ''}
${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 `<span class="text-xs px-1.5 py-0.5 rounded bg-amber-900/50 text-amber-300 ml-1 cursor-pointer" onclick="decisionsTagFilterSet('${d.tag}')">${label}</span>`;
})() : ''}
</div>
<span class="text-xs text-slate-500">${d.timestamp?.slice(5,16)||''}</span>
</div>
<!-- 双维度策略 v&#115; 执行 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-2">
<!-- 策略理论 -->
<div class="bg-blue-900/10 rounded-lg p-3 border border-blue-900/30">
<div class="text-xs font-semibold text-blue-400 mb-1">📐 策略理论</div>
${hasTrigger ? `
<div class="grid grid-cols-3 gap-1 text-xs">
${stopLoss ? `<div><span class="text-red-400">止损</span> ${stopLoss}</div>` : ''}
${entryLow ? `<div><span class="text-yellow-400">买入区</span> ${entryLow}~${entryHigh}</div>` : tg.entry_zone ? `<div><span class="text-yellow-400">买入区</span> ${tg.entry_zone}</div>` : ''}
${takeProfit ? `<div><span class="text-green-400">止盈</span> ${takeProfit}</div>` : ''}
</div>
${action ? `<div class="text-xs text-slate-300 mt-1 font-medium">${action}</div>` : ''}
${analysis.reasoning ? `<div class="text-xs text-slate-400 mt-1">${analysis.reasoning.slice(0,100)}</div>` : ''}
${timingSignal && !timingSignal.includes('neutral') ? `<div class="text-xs text-yellow-400 mt-1">⏱ ${timingSignal}</div>` : ''}
` : `<div class="text-xs text-slate-500">无策略</div>`}
</div>
<!-- 执行实际 -->
<div class="bg-green-900/10 rounded-lg p-3 border border-green-900/30">
<div class="text-xs font-semibold text-green-400 mb-1"> 实际执行</div>
${execStatus === 'executing' || execStatus === 'partial_exit' ? `
<div class="text-xs">
<div class="flex justify-between"><span class="text-slate-400">建仓价</span><span class="font-mono text-white">¥${execPrice||'?'}</span></div>
<div class="flex justify-between"><span class="text-slate-400">持仓</span><span class="font-mono text-white">${execShares||'?'}股</span></div>
<div class="flex justify-between"><span class="text-slate-400">盈亏</span><span class="font-mono ${(execPnl||'').includes('-')?'text-red-400':'text-green-400'}">${execPnl||'?'}</span></div>
${exec.remaining_shares ? `<div class="flex justify-between"><span class="text-slate-400">剩余</span><span class="font-mono text-white">${exec.remaining_shares}股</span></div>` : ''}
${exec.realized_gain ? `<div class="flex justify-between"><span class="text-slate-400">已实现</span><span class="font-mono text-green-400">+${exec.realized_gain}</span></div>` : ''}
${exec.notes ? `<div class="text-slate-400 mt-1">${exec.notes}</div>` : ''}
</div>
` : execStatus === 'observing' ? `
<div class="text-xs text-slate-400">观察中,等待入场时机</div>
<div class="text-xs text-slate-500 mt-1">${execNotes||'关注回调至买入区'}</div>
` : `
<div class="text-xs text-slate-500">尚未执行</div>
`}
</div>
</div>
<!-- 建议记录 -->
${timeline.length ? `
<div class="border-t border-slate-800/50 pt-2 mt-1">
<div class="text-xs text-slate-500 mb-1">📜 建议记录 (${timeline.length})</div>
<div class="space-y-1">
${timeline.slice(-3).map(a =>
`<div class="text-xs text-slate-400 pl-2 border-l-2 border-blue-500/50 flex items-center gap-1">
<span class="text-slate-500">${a.date?.slice(5,16)||''}</span>
<span class="${a.direction==='买入'?'text-green-400':a.direction==='卖出'?'text-red-400':'text-slate-300'}">${a.direction||''}</span>
<span>${a.summary?.slice(0,40)||''}</span>
${a.status === 'pending' ? '<span class="text-yellow-500">⏳</span>' : a.status === 'confirmed' ? '<span class="text-green-500">✓</span>' : a.status === 'ignored' ? '<span class="text-slate-600">✗</span>' : ''}
</div>`
).join('')}
</div>
</div>
` : ''}
${d.updated_reason ? `<div class="text-xs text-slate-500 mt-1">📝 ${d.updated_reason}</div>` : ''}
</div>`;
}).join('')}</div>`;
}
// ── Evaluation Tab ──
function renderEvaluation() {
const el = document.getElementById('tab-evaluation');
@@ -1127,7 +742,11 @@ function renderPrompts() {
// 提示词列表
if (prompts.length === 0) {
html += '<div class="card p-6 text-center text-slate-500">暂无提示词记录,请先初始化注册表。</div>';
html += '<div class="card p-6 text-center">' +
'<div class="text-slate-400 mb-2">⚠️ 提示词注册表未初始化</div>' +
'<div class="text-xs text-slate-500">请在服务器运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'<div class="text-xs text-slate-600 mt-2">路径: /home/hmo/MoFin/prompt_manager/init_registry.py</div>' +
'</div>';
} else {
html += '<div class="card overflow-hidden"><table class="w-full text-sm">';
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700/50"><th class="p-3 font-medium">提示词</th><th class="p-3 font-medium">分类</th><th class="p-3 font-medium">当前版本</th><th class="p-3 font-medium">版本数</th><th class="p-3 font-medium">关联策略</th><th class="p-3 font-medium">成功率</th><th class="p-3 font-medium">操作</th></tr></thead><tbody>';
@@ -1161,7 +780,11 @@ function renderPrompts() {
// 保存数据供版本弹窗使用
window._promptData = data;
}).catch(err => {
el.innerHTML = `<div class="card p-6 text-center text-red-400">加载失败: ${err.message}</div>`;
el.innerHTML = '<div class="card p-6 text-center">' +
'<div class="text-amber-400 mb-2">⚠️ 提示词注册表加载失败</div>' +
'<div class="text-xs text-slate-400">错误: ' + (err.message || 'unknown') + '</div>' +
'<div class="text-xs text-slate-500 mt-2">请确认服务器已运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'</div>';
});
}
@@ -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 = '<div class="card p-6 text-center text-slate-500 text-sm">暂无有效操作建议</div>';
el.innerHTML = '<div class="p-6 text-center text-slate-500 text-sm">暂无策略数据</div>';
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 += `
<div class="card p-4 flex items-center gap-3" onclick="openStock('${s.code}')" style="cursor:pointer">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm text-slate-200">${s.code}</span>
<span class="text-slate-400 text-xs">${s.name || ''}</span>
<span class="text-xs px-1.5 py-0.5 rounded ${sigColor}">${sig}</span>
<span class="text-xs text-slate-500">${s.decision_type==='持仓策略'?'💼':'⭐'}</span>
</div>
<div class="flex items-center gap-3 mt-1 text-xs text-slate-400">
<span class="font-mono">${p.toFixed(2)}</span>
<span class="font-mono ${chgColor}">${cp >= 0 ? '+' : ''}${cp.toFixed(2)}%</span>
<span>区${el_.toFixed(1)}~${eh_.toFixed(1)}</span>
<span>${inZone}</span>
<span>RR ${rr.toFixed(2)}</span>
<span>损${sl_.toFixed(1)}${tp_.toFixed(1)}</span>
</div>
</div>
<div class="text-right text-xs">
<div class="text-slate-400">仓位</div>
<div class="text-yellow-400 font-mono">${s.position_advice || '—'}</div>
</div>
</div>`;
// 分组渲染
const groups = [
{ label: '🔥 重点推荐', g: 0 },
{ label: '💼 持仓策略', g: 1 },
{ label: '⭐ 自选策略', g: 2 },
];
let html = '<div class="overflow-x-auto"><table class="w-full text-xs">';
html += '<thead><tr class="text-slate-500 border-b border-slate-800/50">' +
'<th class="text-left p-2">股票</th>' +
'<th class="text-right p-2">现价</th>' +
'<th class="text-right p-2">涨跌%</th>' +
'<th class="text-left p-2">信号</th>' +
'<th class="text-right p-2">买入区间</th>' +
'<th class="text-right p-2">止损/止盈</th>' +
'<th class="text-right p-2">RR</th>' +
'<th class="text-right p-2">仓位</th>' +
'<th class="text-left p-2">当前操作策略</th>' +
'<th class="text-center p-2">操作策略</th>' +
'</tr></thead><tbody>';
for (const grp of groups) {
const items = stocks.filter(s => s.sort_group === grp.g);
if (items.length === 0) continue;
// 分组标题行
html += '<tr class="border-b border-slate-700">' +
'<td colspan="10" class="p-2 font-semibold text-slate-300">' + grp.label + ' (' + items.length + ')</td>' +
'</tr>';
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 ? '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-1">🔥推荐</span>' : '';
// 推荐行策略文本加粗醒目
const stratCls = isRec ? 'font-semibold text-amber-200' : 'text-slate-300';
html += '<tr class="' + rowCls + '" onclick="openStock(\'' + s.code + '\')" style="cursor:pointer">' +
'<td class="p-2"><span class="font-medium text-white">' + s.name + '</span><br><span class="text-slate-500">' + s.code + '</span>' + recBadge + '</td>' +
'<td class="p-2 text-right font-mono">' + (p ? p.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono ' + chgColor + '">' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%</td>' +
'<td class="p-2 text-left"><span class="text-xs px-2 py-1 rounded ' + sigCls + '">' + (sig || '—') + '</span></td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + buyZone + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + sltp + '</td>' +
'<td class="p-2 text-right font-mono ' + (rr >= 1.5 ? 'text-green-400' : rr > 0 ? 'text-yellow-400' : 'text-slate-500') + '">' + (rr ? rr.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
'<td class="p-2 ' + stratCls + ' max-w-[200px] break-all line-clamp-2">' + strat + '</td>' +
'<td class="p-2 text-center"><button class="text-xs px-2 py-1 rounded bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 transition" onclick="event.stopPropagation();openStrategyHistory(\'' + s.code + '\')">📋 历史</button></td>' +
'</tr>';
}
}
html += '</tbody></table></div>';
// 更新时间
html += '<div class="text-xs text-slate-600 mt-1">' + (data.updated_at ? '数据: ' + data.updated_at : '') + '</div>';
el.innerHTML = html;
// 自动刷新:每30秒
watchTimer = setInterval(renderWatch, 30000);
} catch(e) {
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
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 = '<div class="text-slate-500">正在获取策略历史...</div>';
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 = '<div class="text-slate-500 text-center py-8">暂无策略历史记录</div>';
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 += '<div class="card p-4">' +
'<div class="flex justify-between items-start mb-2">' +
'<div>' +
'<span class="font-semibold text-white">' + (action || '—') + '</span>' +
'<span class="text-xs px-2 py-0.5 rounded ml-2 ' + sigCls + '">' + (timing || '—') + '</span>' +
'<div class="text-xs text-slate-500 mt-1">' + headInfo.join(' · ') + '</div>' +
'</div>' +
(isLatest ? '' : '<button class="text-xs text-blue-400 hover:text-blue-300" onclick="toggleStrat(\'' + toggleId + '\')">展开</button>') +
'</div>';
// 策略参数
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 += '<div class="text-xs text-slate-300 mb-2">' + params.join(' · ') + '</div>';
}
// 完整分析(折叠)
const fa = h.full_analysis || '';
if (fa) {
body += '<details class="text-xs">' +
'<summary class="text-slate-400 cursor-pointer hover:text-blue-400">📋 完整分析</summary>' +
'<pre class="mt-1 text-slate-300 whitespace-pre-wrap bg-slate-900/50 rounded p-2 max-h-48 overflow-y-auto font-sans leading-relaxed">' + fa.replace(/</g, '&lt;') + '</pre>' +
'</details>';
}
body += '</div>';
}
document.getElementById('strategyModalBody').innerHTML = body;
} catch(e) {
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
document.getElementById('strategyModalMeta').textContent = code;
document.getElementById('strategyModalBody').innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
}
}
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() {
+94 -3
View File
@@ -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}
</style></head>
<body>
<h1>📊 MoFin 系统健康监控</h1>
@@ -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 += '<option value="position-analyst">📋 知微</option>';
html += '<option value="all">📋+📦 全部</option>';
html += '</select></div>';
html += '<table><thead><tr><th>名称</th><th>来源</th><th>脚本/LLM</th><th>类型</th><th>调度</th><th>状态</th><th>最后运行</th></tr></thead><tbody id="pipeBody">';
pipelines.forEach(p => {
html += '<table><thead><tr><th>名称</th><th>来源</th><th>脚本/LLM</th><th>类型</th><th>调度</th><th>状态</th><th>最后运行</th><th>最后十次</th></tr></thead><tbody id="pipeBody">';
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 匹配 keypipeline 中文名(服务端经 jobs.json 解析为 job id+ 脚本名兜底
const encName = encodeURIComponent(p.name || '');
const encScript = encodeURIComponent(p.script || '');
html += `<tr class="pipe-row" data-profile="${p.profile||'default'}" data-name="${(p.name||'').toLowerCase()}"><td>${p.name||p.script||'LLM'}</td>`;
html += `<td style="font-size:11px">${profileBadge} ${p.profile||'?'}</td>`;
html += `<td style="font-size:11px">${p.script||'LLM'}</td>`;
html += `<td><span class="cron-tag ${tagCls}">${p.type||'cron'}</span></td>`;
html += `<td style="font-size:11px">${p.schedule||'-'}</td>`;
html += `<td class="pipeline-${tagCls}">${statusDisplay}</td>`;
html += `<td style="font-size:11px">${p.last_run||'-'}</td></tr>`;
html += `<td style="font-size:11px">${p.last_run||'-'}</td>`;
html += `<td><button class="pipe-reports-btn" data-cron="${encName}" data-script="${encScript}" data-name="${(p.name||'').replace(/"/g,'&quot;')}" onclick="openLastReports(this)">最后十次</button></td></tr>`;
});
html += '</tbody></table>';
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 = `
<div class="rpt-modal">
<h3>📋 ${pipelineName} · 最后十次报告 <button class="close-btn" onclick="closeReportModal()">&times;</button></h3>
<div id="reportList" class="rpt-list">加载中...</div>
</div>`;
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 = '<div style="color:#8b949e;text-align:center;padding:20px">暂无报告记录</div>';
return;
}
list.innerHTML = reports.map(r => `
<div class="rpt-item" onclick="viewReportDetail('${r.id}')">
<div class="rpt-title">${r.title || r.id}</div>
<div class="rpt-meta">${r.type || '未知'} · ${r.created_at ? r.created_at.slice(0,16) : ''}${r.summary ? ' · ' + r.summary.slice(0,60) : ''}</div>
</div>`).join('');
})
.catch(e => {
document.getElementById('reportList').innerHTML = '<div style="color:#f85149">加载失败: ' + e.message + '</div>';
});
}
function viewReportDetail(reportId) {
const list = document.getElementById('reportList');
list.innerHTML = `
<span class="rpt-back" onclick="closeReportModal();">← 返回列表</span>
<div style="color:#8b949e;font-size:11px;margin-bottom:8px">正在加载报告: ${reportId}...</div>
`;
fetch('/api/report/' + reportId)
.then(r => r.json())
.then(d => {
if (d.error) { list.innerHTML = '<div style="color:#f85149">报告不存在: ' + d.error + '</div>'; return; }
const title = d.title || reportId;
const type = d.type || '未知';
const created = d.created_at || '';
const content = d.content || d.summary || '';
list.innerHTML = `
<span class="rpt-back" onclick="closeReportModal();">← 返回列表</span>
<div style="margin-bottom:8px">
<strong style="color:#c9d1d9">${title}</strong>
<span style="color:#8b949e;margin-left:8px;font-size:11px">${type} · ${created}</span>
</div>
<div class="rpt-detail">${content.replace(/</g, '&lt;')}</div>
`;
})
.catch(e => {
list.innerHTML = '<div style="color:#f85149">加载失败: ' + e.message + '</div>';
});
}
</script>
</body></html>