重评核心重构: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:
@@ -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):
|
||||
"""单个报告详情"""
|
||||
|
||||
Reference in New Issue
Block a user