重评核心重构: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:
@@ -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}跳过")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
# ----- 结束 自选股重评 -----
|
||||
|
||||
+88
-7
@@ -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} 已写入"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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">×</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">×</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">距关键位<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>
|
||||
|
||||
<!-- 双维度:策略 vs 执行 -->
|
||||
<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, '<') + '</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() {
|
||||
|
||||
@@ -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 匹配 key:pipeline 中文名(服务端经 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,'"')}" 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()">×</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, '<')}</div>
|
||||
`;
|
||||
})
|
||||
.catch(e => {
|
||||
list.innerHTML = '<div style="color:#f85149">加载失败: ' + e.message + '</div>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user