重评核心重构: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
+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):
"""单个报告详情"""