refactor: 归档策略进化模块+新建评估页面+API
- 归档 evolution/ + meta_growth/meta_watchdog/ab_research_daily - docs/evolution-archive-readme.md: 归档说明(旧模块功能+替代方案) - server.py: 新增 /api/research/effectiveness + effectiveness/summary + recommendation_log + execution_log - static/effectiveness.html: 新评估页面(概览/详细评估/推荐记录/执行记录) - 策略进化改为人驱动闭环(评估→用户决策→调整)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""AB路线每日LLM主导研究 v2(老莫2026-08-18)
|
||||
在原规则化分析基础上,集成 LLM 生成深度研究结论(真正"LLM主导")
|
||||
1. 读温区覆盖 + 进化中心 + B组候选
|
||||
2. LLM 分析薄弱环节 → 建议尝试
|
||||
3. 写 strategy_research_log 表
|
||||
"""
|
||||
import sys, os, json, sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
DB = "/home/hmo/MoFin/data/mofin.db"
|
||||
CENTER = "/home/hmo/MoFin/data/evolution_center.json"
|
||||
|
||||
def ensure_table(conn):
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS strategy_research_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, log_date TEXT NOT NULL, market TEXT,
|
||||
weak_regime TEXT, finding TEXT, experiment TEXT, result TEXT,
|
||||
produced_strategy TEXT, produced_verified INTEGER DEFAULT 0, llm_model TEXT, created_at TEXT)""")
|
||||
conn.commit()
|
||||
|
||||
def load_center():
|
||||
if not os.path.exists(CENTER): return {}
|
||||
try: return json.load(open(CENTER))
|
||||
except: return {}
|
||||
|
||||
def build_prompt(coverage, center):
|
||||
"""构造 LLM 研究 prompt"""
|
||||
line = []
|
||||
line.append("你是MoFin策略研究员。分析当前策略覆盖,找出薄弱环节并给出研究建议。")
|
||||
line.append("温区覆盖(trades>=30,2y):")
|
||||
for c in coverage:
|
||||
line.append(f"- {c['market']}/{c['regime']}: {c['count']}个策略")
|
||||
bg = center.get("b_group") or []
|
||||
if bg:
|
||||
line.append(f"B组候选: {len(bg)}条")
|
||||
for b in bg[:3]:
|
||||
line.append(f" - {str(b)[:80]}")
|
||||
line.append("\n请输出:")
|
||||
line.append("1. 最薄弱的温区/环节(策略匮乏或合格策略少)")
|
||||
line.append("2. 具体研究建议(做什么尝试)")
|
||||
line.append("3. 预期成果类型")
|
||||
line.append("格式:发现|建议|预期")
|
||||
return "\n".join(line)
|
||||
|
||||
def analyze_llm(coverage, center):
|
||||
"""LLM 生成研究结论"""
|
||||
try:
|
||||
from llm_client import call_llm
|
||||
prompt = build_prompt(coverage, center)
|
||||
res = call_llm(prompt)
|
||||
return str(res)[:400] if res else None
|
||||
except Exception as e:
|
||||
return f"[LLM调用失败: {e}]"
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB, timeout=30)
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
ensure_table(conn)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
if conn.execute("SELECT COUNT(*) FROM strategy_research_log WHERE log_date=?", (today,)).fetchone()[0]:
|
||||
print(f"[AB研究] {today} 已有记录"); conn.close(); return
|
||||
# 读取覆盖
|
||||
rows = conn.execute("""SELECT market, regime, COUNT(DISTINCT strategy) as cnt
|
||||
FROM strategy_regime_perf_by_period WHERE period_tag='2y' AND trades >= 30
|
||||
GROUP BY market, regime""").fetchall()
|
||||
coverage = [{"market": r[0], "regime": r[1], "count": r[2]} for r in rows]
|
||||
center = load_center()
|
||||
# 基础规则发现
|
||||
findings = []
|
||||
weak = []
|
||||
if coverage:
|
||||
c_sorted = sorted(coverage, key=lambda x: x["count"])
|
||||
weak = c_sorted[:2]
|
||||
findings.append("覆盖最少的温区: " + "; ".join(f"{c['market']}/{c['regime']}({c['count']})" for c in weak))
|
||||
# LLM 深度分析
|
||||
if coverage:
|
||||
llm_res = analyze_llm(coverage, center)
|
||||
if llm_res:
|
||||
findings.append("LLM分析: " + llm_res)
|
||||
finding_text = "; ".join(findings) or "无明显薄弱点"
|
||||
weak_rg = weak[0]["regime"] if weak else ""
|
||||
weak_mkt = weak[0]["market"] if weak else "a"
|
||||
conn.execute(
|
||||
"INSERT INTO strategy_research_log (log_date, market, weak_regime, finding, experiment, result, produced_strategy, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(today, weak_mkt, weak_rg, finding_text, "LLM主导温区覆盖+B组分析", "记录待验证", "",
|
||||
datetime.now().isoformat()))
|
||||
conn.commit()
|
||||
print(f"[AB研究] {today} 记录完成 (LLM主导)")
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
evolution/__init__.py — 自我进化模块
|
||||
Loop Engineering: 策略健康度监控 + 教训提取 + 自动迭代
|
||||
"""
|
||||
import sqlite3, os
|
||||
|
||||
DB = os.environ.get('MOFIN_DB', '/home/hmo/MoFin/data/mofin.db')
|
||||
|
||||
def init_evolution_tables(conn=None):
|
||||
"""初始化进化模块数据表"""
|
||||
close_conn = False
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(DB)
|
||||
close_conn = True
|
||||
|
||||
# 策略健康度每日快照
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_health (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_version TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
live_trades INTEGER DEFAULT 0,
|
||||
live_wins INTEGER DEFAULT 0,
|
||||
live_return_pct REAL DEFAULT 0,
|
||||
backtest_wr REAL DEFAULT 0,
|
||||
backtest_avg_ret REAL DEFAULT 0,
|
||||
deviation REAL DEFAULT 0,
|
||||
health_score REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(strategy_version, date)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_version_date ON strategy_health(strategy_version, date)")
|
||||
|
||||
# 教训库
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_version TEXT NOT NULL,
|
||||
trade_id INTEGER,
|
||||
lesson_type TEXT NOT NULL,
|
||||
lesson_text TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
applied INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_lessons_version ON strategy_lessons(strategy_version, applied)")
|
||||
|
||||
# 策略迭代历史
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS strategy_evolution (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_version TEXT NOT NULL,
|
||||
child_version TEXT NOT NULL,
|
||||
change_description TEXT,
|
||||
backtest_result TEXT,
|
||||
promoted INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_evolution_parent ON strategy_evolution(parent_version, promoted)")
|
||||
|
||||
conn.commit()
|
||||
if close_conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_evolution_tables()
|
||||
print("进化模块数据表初始化完成")
|
||||
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""evolution/b_group_miner.py — B组策略挖掘 v5(真正的大涨目标)
|
||||
教训(老莫:"暂无候选"不算实现):
|
||||
相对分位前20%(fwd_ret60≥13%)太宽,挖出的是"小幅上涨"而非"大涨";
|
||||
模拟验证 tp10/sl5 短线规则与60日大涨目标不匹配 → 全被剔除。
|
||||
修正:
|
||||
果 = fwd_ret60 >= 30%(绝对大涨,趋势市基线7.8%)
|
||||
因子组合扫描找大涨率显著提升
|
||||
模拟验证用匹配大涨的规则(tp20%/sl10%/maxh40)+ 扫描最优参数
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from itertools import combinations
|
||||
|
||||
DATA_DIR = "/home/hmo/MoFin/data"
|
||||
OUT_JSON = f"{DATA_DIR}/b_group_candidates.json"
|
||||
BIG_TH = 30 # 大涨目标
|
||||
|
||||
|
||||
def load_regime_map(market="a"):
|
||||
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
|
||||
rows = conn.execute("SELECT date, regime FROM market_regime WHERE market=?", (market,)).fetchall()
|
||||
conn.close()
|
||||
return {d: r for d, r in rows}
|
||||
|
||||
|
||||
def load_panel(market):
|
||||
path = "/tmp/panel_12d_hk.pkl" if market == "hk" else "/tmp/panel_12d.pkl"
|
||||
p = pd.read_pickle(path)
|
||||
p = p.sort_values(["code", "date"]).reset_index(drop=True)
|
||||
p["fwd_ret60"] = p.groupby("code")["close"].transform(lambda x: x.shift(-60) / x - 1) * 100
|
||||
return p
|
||||
|
||||
|
||||
def scan_big(market, regime, panel, min_n=500):
|
||||
"""扫描因子组合:找绝对大涨率显著提升的组合"""
|
||||
rm = load_regime_map(market)
|
||||
p = panel.copy()
|
||||
p["_regime"] = p["date"].map(rm)
|
||||
sub = p[p["_regime"] == regime].dropna(subset=["fwd_ret60"])
|
||||
if len(sub) < min_n:
|
||||
return []
|
||||
sub["is_big"] = (sub["fwd_ret60"] >= BIG_TH).astype(int)
|
||||
br = sub["is_big"].mean() * 100
|
||||
print(f"[{market}/{regime}] 样本{len(sub)} 基线大涨率(60d>={BIG_TH}%){br:.1f}%")
|
||||
|
||||
# 因子池(方向:大盘弱 + 个股超跌 + 小盘低估值 + 基本面催化)
|
||||
factor_defs = {
|
||||
"mkt_ret20": ("<", 0), "mkt_rsi": ("<", 50), "mkt_adx": (">", 20),
|
||||
"bias60": ("<", -10), "rsi": ("<", 40), "dist_lo20": (">", 5),
|
||||
"mcap_q": ("<", 0.3), "pe_q": ("<", 0.3), "pb_q": ("<", 0.3),
|
||||
"sec_ret20": ("<", 0), "news3": (">=", 1), "vol_ratio": (">", 1.2),
|
||||
"ret20": ("<", 0), "flow5": (">", 0),
|
||||
}
|
||||
# 单条件测试
|
||||
single = []
|
||||
for feat, (op, val) in factor_defs.items():
|
||||
if feat not in sub.columns:
|
||||
continue
|
||||
cond = sub[feat] < val if op == "<" else sub[feat] > val
|
||||
m = sub[cond]
|
||||
if len(m) < 200:
|
||||
continue
|
||||
rate = m["is_big"].mean() * 100
|
||||
if rate > br + 0.5: # 单条件提升>0.5pp 进组合池(多因子叠加才有大提升)
|
||||
single.append((feat, round(rate, 1), len(m), round(rate - br, 1)))
|
||||
single.sort(key=lambda x: -x[3])
|
||||
print(" 单条件:", single[:6])
|
||||
|
||||
# 4-6 因子组合(从单条件提升>0.5pp 里取 8 个,测 4/5/6 组合)
|
||||
pool = [s[0] for s in single if s[3] > 0.5][:8]
|
||||
results = []
|
||||
for k in [4, 5, 6]:
|
||||
for combo in combinations(pool, k):
|
||||
cond = pd.Series(True, index=sub.index)
|
||||
for feat in combo:
|
||||
op, val = factor_defs[feat]
|
||||
cond &= (sub[feat] < val) if op == "<" else (sub[feat] > val)
|
||||
m = sub[cond]
|
||||
if len(m) < 200:
|
||||
continue
|
||||
rate = m["is_big"].mean() * 100
|
||||
avg = m["fwd_ret60"].mean()
|
||||
results.append(({f: factor_defs[f] for f in combo}, len(m), round(rate, 1),
|
||||
round(avg, 1), round(rate - br, 1), len(combo)))
|
||||
results.sort(key=lambda x: -x[4])
|
||||
return results[:5]
|
||||
|
||||
|
||||
def _simulate_verify(market, regime, panel, cond, tp=20, sl=10, maxh=40):
|
||||
"""模拟验证:候选在温区的模拟交易(大涨匹配规则)"""
|
||||
rm = load_regime_map(market)
|
||||
sub = panel.copy()
|
||||
sub["_regime"] = sub["date"].map(rm)
|
||||
sub = sub[(sub["_regime"] == regime) & cond].copy()
|
||||
if len(sub) < 200:
|
||||
return None
|
||||
sub = sub.sort_values(["code", "date"])
|
||||
trades = []
|
||||
trade_details = []
|
||||
for code, g in sub.groupby("code"):
|
||||
g = g.sort_values("date")
|
||||
idxs = list(g.index)
|
||||
for k, i in enumerate(idxs):
|
||||
fut = g.iloc[k+1:k+maxh+1]
|
||||
if len(fut) < 2:
|
||||
continue
|
||||
ep = g.loc[i, "close"]
|
||||
if ep <= 0:
|
||||
continue
|
||||
res = None
|
||||
hold_days = maxh
|
||||
for j, (_, fb) in enumerate(fut.iterrows()):
|
||||
if fb["close"] <= ep * (1 - sl / 100):
|
||||
res = -sl
|
||||
hold_days = j + 1
|
||||
break
|
||||
if fb["close"] >= ep * (1 + tp / 100):
|
||||
res = tp
|
||||
hold_days = j + 1
|
||||
break
|
||||
if res is None:
|
||||
res = (fut.iloc[-1]["close"] / ep - 1) * 100
|
||||
hold_days = len(fut)
|
||||
trades.append(res)
|
||||
trade_details.append({"entry_date": str(g.loc[i, "date"]), "pnl_pct": round(res, 2),
|
||||
"profit_pct": round(res, 2), "hold_days": hold_days,
|
||||
"code": str(code)})
|
||||
if not trades:
|
||||
return None
|
||||
wins = [x for x in trades if x > 0]
|
||||
if not trades:
|
||||
return None
|
||||
return {"n": len(trades), "win_rate": len(wins) / len(trades) * 100,
|
||||
"avg_pnl": sum(trades) / len(trades), "trades": trade_details}
|
||||
|
||||
|
||||
def to_entry(cond_dict):
|
||||
entry = {}
|
||||
for feat, (op, val) in cond_dict.items():
|
||||
key = feat + ("_min" if (op == ">" or op == ">=") else "_max")
|
||||
entry[key] = float(val)
|
||||
return entry
|
||||
|
||||
|
||||
def mine(market="a", regimes=None):
|
||||
regimes = regimes or ["trend_up", "choppy", "trend_down"]
|
||||
panel = load_panel(market)
|
||||
out = {"market": market, "mined_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "candidates": []}
|
||||
for rg in regimes:
|
||||
combos = scan_big(market, rg, panel)
|
||||
for cond, n, rate, avg, extra, nf in combos[:3]:
|
||||
c = pd.Series(True, index=panel.index)
|
||||
for feat, (op, val) in cond.items():
|
||||
if feat not in panel.columns:
|
||||
c = None
|
||||
break
|
||||
c &= (panel[feat] < val) if op == "<" else (panel[feat] > val)
|
||||
verified = None
|
||||
if c is not None:
|
||||
# 多参数模拟验证,取最优
|
||||
# 先筛胜率≥50%的参数,再取其中收益最高(2026-08-16 修正:原取收益最高可能选中胜率<50%参数)
|
||||
passed_params = []
|
||||
for tp, sl, mh in [(20, 10, 40), (25, 10, 45), (30, 12, 50), (15, 8, 35)]:
|
||||
r = _simulate_verify(market, rg, panel, c, tp, sl, mh)
|
||||
if r and r["win_rate"] >= 50 and r["avg_pnl"] > 0:
|
||||
passed_params.append((tp, sl, mh, r))
|
||||
if passed_params:
|
||||
best = max(passed_params, key=lambda x: x[3]["avg_pnl"])
|
||||
tp, sl, mh, rd = best
|
||||
tn, twr, tavg = rd["n"], rd["win_rate"], rd["avg_pnl"]
|
||||
if twr >= 50 and tavg > 0:
|
||||
cand = {
|
||||
"regime": rg, "market": market, "group": "B", "status": "verified",
|
||||
"entry": to_entry(cond), "trades_est": n, "big_rate": rate,
|
||||
"avg60": avg, "excess_pp": extra,
|
||||
"sim_trades": tn, "sim_win_rate": round(twr, 1), "sim_avg_pnl": round(tavg, 2),
|
||||
"sim_tp": tp, "sim_sl": sl, "sim_maxh": mh,
|
||||
"trades": rd.get("trades", []),
|
||||
"hypothesis": f"[{rg}] 由果及因{nf}因子: {list(cond.keys())} → 大涨率{rate}%(基线+{extra}pp)",
|
||||
}
|
||||
out["candidates"].append(cand)
|
||||
print(f" [{rg}] {list(cond.keys())} ✅大涨率{rate}% 模拟胜率{twr:.0f}%/均{tavg:.2f}%", flush=True)
|
||||
else:
|
||||
print(f" [{rg}] {list(cond.keys())} 模拟未达标(胜率{twr:.0f}%/均{tavg:.2f}%) 剔除", flush=True)
|
||||
else:
|
||||
print(f" [{rg}] {list(cond.keys())} 模拟无结果 剔除", flush=True)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
market = sys.argv[1] if len(sys.argv) > 1 else "a"
|
||||
res = mine(market)
|
||||
with open(OUT_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(res, f, ensure_ascii=False, indent=1)
|
||||
print(f"写入 {OUT_JSON}: {len(res['candidates'])} 个候选")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
evolution/evolution_api.py — 进化模块 API 接口
|
||||
供 dashboard 查询健康度、教训、迭代历史
|
||||
"""
|
||||
import sys, os, json, sqlite3
|
||||
|
||||
sys.path.insert(0, '/home/hmo/MoFin')
|
||||
sys.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts')
|
||||
|
||||
DB = os.environ.get('MOFIN_DB', '/home/hmo/MoFin/data/mofin.db')
|
||||
|
||||
|
||||
def get_evolution_dashboard():
|
||||
"""进化模块 Dashboard 数据"""
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# 最近健康度(近30天)
|
||||
health = []
|
||||
for r in conn.execute("""
|
||||
SELECT strategy_version, date, live_trades, live_wins, live_return_pct,
|
||||
backtest_wr, backtest_avg_ret, deviation, health_score
|
||||
FROM strategy_health ORDER BY date DESC LIMIT 30
|
||||
""").fetchall():
|
||||
health.append(dict(r))
|
||||
|
||||
# 最近教训(近20条)
|
||||
lessons = []
|
||||
for r in conn.execute("""
|
||||
SELECT strategy_version, lesson_type, lesson_text, confidence, applied, created_at
|
||||
FROM strategy_lessons ORDER BY id DESC LIMIT 20
|
||||
""").fetchall():
|
||||
lessons.append(dict(r))
|
||||
|
||||
# 迭代历史
|
||||
evolution = []
|
||||
for r in conn.execute("""
|
||||
SELECT parent_version, child_version, change_description, promoted, created_at
|
||||
FROM strategy_evolution ORDER BY id DESC LIMIT 20
|
||||
""").fetchall():
|
||||
evolution.append(dict(r))
|
||||
|
||||
# 当前策略基线(2026-08-15: 数据驱动——跟随 strategy_weights.json 激活集合,
|
||||
# 原硬编码 ['v_weak','v_oversold'] 与温区路由脱节,激活策略换了一批但基线还显示旧的)
|
||||
def _active_versions():
|
||||
try:
|
||||
d = json.loads(open('/home/hmo/MoFin/data/strategy_weights.json', encoding='utf-8').read())
|
||||
vs = list(d.get('active') or [])
|
||||
vs += list(((d.get('markets') or {}).get('hk') or {}).get('active') or [])
|
||||
seen, out = set(), []
|
||||
for v in vs:
|
||||
if v and v not in seen:
|
||||
seen.add(v)
|
||||
out.append(v)
|
||||
return out or ['v_weak', 'v_oversold']
|
||||
except Exception:
|
||||
return ['v_weak', 'v_oversold']
|
||||
baseline = {}
|
||||
for v in _active_versions():
|
||||
r = conn.execute("""
|
||||
SELECT results_json FROM strategy_research
|
||||
WHERE version=? AND period_tag='5y' ORDER BY id DESC LIMIT 1
|
||||
""", (v,)).fetchone()
|
||||
if r:
|
||||
res = json.loads(r[0])
|
||||
s = res.get('summary', {})
|
||||
pf = s.get('portfolio_full', {})
|
||||
baseline[v] = {
|
||||
'win_rate': s.get('win_rate', 0),
|
||||
'total_return': pf.get('total_return_pct', 0),
|
||||
'cagr': pf.get('cagr_pct', 0),
|
||||
'max_dd': pf.get('portfolio_max_dd_pct', 0),
|
||||
}
|
||||
|
||||
# ── 2026-08-16 进化机制数据:读预计算快照(precompute_evolution.py 定期生成,避免实时重算)──
|
||||
hypotheses = []
|
||||
b_group = []
|
||||
qual_overview = []
|
||||
try:
|
||||
_ec = json.loads(open('/home/hmo/MoFin/data/evolution_center.json', encoding='utf-8').read())
|
||||
hypotheses = _ec.get('hypotheses', [])
|
||||
b_group = _ec.get('b_group', [])
|
||||
qual_overview = _ec.get('qual_overview', [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'health': health,
|
||||
'lessons': lessons,
|
||||
'evolution': evolution,
|
||||
'baseline': baseline,
|
||||
'hypotheses': hypotheses,
|
||||
'b_group': b_group,
|
||||
'qual_overview': qual_overview,
|
||||
}
|
||||
|
||||
|
||||
def get_combo_dashboard():
|
||||
"""组合方案 Dashboard 数据 (2026-08-02 新增)
|
||||
返回: 当前组合方案(v_next4+v_mr按regime分工) + 组合回测版本(v_combo) + 市场阶段
|
||||
"""
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# 1. 当前市场阶段 (market_regime)
|
||||
regime = None
|
||||
r = conn.execute("SELECT * FROM market_regime ORDER BY date DESC LIMIT 1").fetchone()
|
||||
if r:
|
||||
regime = dict(r)
|
||||
|
||||
# 2. 组合回测版本 (v_combo 家族)
|
||||
combos = []
|
||||
rows = conn.execute(
|
||||
"SELECT id, version, market, period_tag, created_at, results_json"
|
||||
" FROM strategy_research WHERE version LIKE '%combo%' OR version LIKE 'v_combo%'"
|
||||
" ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
res = json.loads(d.pop("results_json") or "{}")
|
||||
s = res.get("summary", {})
|
||||
pf = s.get("portfolio_full", {})
|
||||
p5 = s.get("portfolio", {})
|
||||
d["summary_stats"] = {
|
||||
"total_trades": s.get("total_trades"),
|
||||
"win_rate": s.get("win_rate"),
|
||||
"avg_profit_pct": s.get("avg_profit_pct"),
|
||||
"avg_hold_days": s.get("avg_hold_days"),
|
||||
"sharpe_ratio": s.get("sharpe_ratio"),
|
||||
"profit_factor": s.get("profit_factor"),
|
||||
"universality": s.get("universality", {}),
|
||||
"portfolio": p5,
|
||||
"portfolio_full": pf,
|
||||
}
|
||||
combos.append(d)
|
||||
|
||||
# 3. 组合成员策略的独立指标
|
||||
# 2026-08-11 更新:组合成员 = v_weak(实盘)+ p_oversold(新策略),替代旧的 v_next4+v_mr
|
||||
# v_next4 移除(池内卫星仓,全市场失效;现有池子票不是它选的)
|
||||
members = {}
|
||||
for v in ["v_weak", "p_oversold"]:
|
||||
sel_v = "v_weak" if v == "v_mr" else None
|
||||
# 2026-08-12: p_oversold 实盘名 → 回测数据存 v_oversold(研究名),两个都查
|
||||
candidates = ["v_oversold", "p_oversold"] if v == "p_oversold" else (["v_weak", "v_mr_sel", v] if sel_v else [v])
|
||||
r = None
|
||||
used_sel = False
|
||||
for cv in candidates:
|
||||
r = conn.execute(
|
||||
"SELECT results_json FROM strategy_research"
|
||||
" WHERE version=? AND period_tag='10y' ORDER BY id DESC LIMIT 1",
|
||||
(cv,),
|
||||
).fetchone()
|
||||
if r:
|
||||
used_sel = (cv == "v_weak")
|
||||
break
|
||||
if r:
|
||||
res = json.loads(r[0])
|
||||
s = res.get("summary", {})
|
||||
pf = s.get("portfolio_full", {})
|
||||
p5 = s.get("portfolio", {})
|
||||
members[v] = {
|
||||
"role": "弱市超跌确认(实盘)" if v == "v_weak" else "预测超跌反弹(新策略)",
|
||||
"version": ("v_weak" if used_sel else "v_mr") if v == "v_mr" else v,
|
||||
"is_sel": used_sel,
|
||||
"trades": s.get("total_trades"),
|
||||
"win_rate": s.get("win_rate"),
|
||||
"avg_profit_pct": s.get("avg_profit_pct"),
|
||||
"avg_hold_days": s.get("avg_hold_days"),
|
||||
"cagr_pct": p5.get("cagr_pct"),
|
||||
"return_pct": p5.get("total_return_pct"),
|
||||
"max_dd_pct": p5.get("portfolio_max_dd_pct"),
|
||||
"slots": p5.get("slots") or 6,
|
||||
"universality": s.get("universality", {}),
|
||||
# 组合模拟实际执行笔数(扣费后) + 年均(手工可行性参考)
|
||||
"positions_taken_5slot": p5.get("positions_taken"),
|
||||
"positions_taken_full": pf.get("positions_taken"),
|
||||
}
|
||||
# 2026-08-11:p_oversold 无回测数据时给兜底卡片(新策略待回测)
|
||||
if "p_oversold" not in members:
|
||||
members["p_oversold"] = {
|
||||
"role": "预测超跌反弹(新策略)",
|
||||
"version": "p_oversold",
|
||||
"is_sel": False,
|
||||
"trades": None, "win_rate": None, "avg_profit_pct": None,
|
||||
"avg_hold_days": None, "cagr_pct": None, "return_pct": None,
|
||||
"max_dd_pct": None, "slots": 10,
|
||||
"universality": {},
|
||||
"positions_taken_5slot": None, "positions_taken_full": None,
|
||||
"note": "新策略,待回测/实盘验证",
|
||||
}
|
||||
|
||||
conn.close()
|
||||
|
||||
# 2026-08-13 温区自适应:并入 strategy_weights.json(当前温区+温度+各策略权重/激活)
|
||||
# + strategy_alerts.json(三振出局状态)
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
_d = _Path("/home/hmo/MoFin/data")
|
||||
weights_data = None
|
||||
alerts_data = None
|
||||
try:
|
||||
_w = _d / "strategy_weights.json"
|
||||
if _w.exists():
|
||||
weights_data = _json.loads(_w.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_a = _d / "strategy_alerts.json"
|
||||
if _a.exists():
|
||||
alerts_data = _json.loads(_a.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"regime": regime,
|
||||
"regime_weights": weights_data, # 当前温区/温度/各策略权重/激活
|
||||
"strategy_alerts": alerts_data, # 三振出局状态
|
||||
"combos": combos,
|
||||
"members": members,
|
||||
"routing": [
|
||||
{"regime": "trend_up", "active": "p_oversold", "action": "预测超跌反弹", "desc": "趋势市/反弹期, p_oversold 预测超跌反弹"},
|
||||
{"regime": "choppy", "active": "v_weak", "action": "弱市超跌确认", "desc": "震荡/下跌市, v_weak 均值回复主战场"},
|
||||
{"regime": "trend_down", "active": "v_weak", "action": "深超跌管理", "desc": "下跌市, v_weak 管理超跌持仓"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_health_trend(version='v_weak', days=30):
|
||||
"""健康度趋势"""
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("""
|
||||
SELECT date, health_score, deviation, live_trades
|
||||
FROM strategy_health WHERE strategy_version=? ORDER BY date DESC LIMIT ?
|
||||
""", (version, days)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def record_evolution(parent, child, description, backtest_result=None, promoted=0):
|
||||
"""记录一次策略迭代"""
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.execute("""
|
||||
INSERT INTO strategy_evolution (parent_version, child_version, change_description, backtest_result, promoted)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (parent, child, description, json.dumps(backtest_result) if backtest_result else None, promoted))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
d = get_evolution_dashboard()
|
||||
print(f"健康度: {len(d['health'])}条, 教训: {len(d['lessons'])}条, 迭代: {len(d['evolution'])}条")
|
||||
print(f"基线: {list(d['baseline'].keys())}")
|
||||
@@ -0,0 +1,438 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
evolution/evolution_engine.py — 策略自我进化引擎(每周六 22:00,hermes cron)
|
||||
|
||||
设计依据:docs/decisions/2026-08-15-策略自我进化闭环重构.md(老莫已批准)
|
||||
闭环:统计数据每日自动更新 → 本引擎每周检测退化 → 生成参数变体 → 回测验证 → 有价值才推送
|
||||
|
||||
流程:
|
||||
1. 读激活策略集合(data/strategy_weights.json:A股 active + 港股 markets.hk.active)
|
||||
2. 退化信号检测(宁缺毋滥,任一命中即触发研究):
|
||||
S1 健康度连续低:strategy_health 连续 5 天 health_score < 40(排除 50 中性=无实盘数据)
|
||||
S2 温区表现衰减:激活策略在其适应温区(strategy_regime_perf)温区级组合年化 cagr_pct < 0
|
||||
3. 有退化 → 生成参数变体:
|
||||
- 只对 lab.STRATEGIES 里可回测的策略(v_oversold/v_weak 等标准回测体系)
|
||||
- 参数空间从策略 config 实际数值字段出发(递归遍历,单变量 ±20%,一次只动一个)
|
||||
- 港股走 hk_backtest(entry/exit 字段 ±20%)
|
||||
4. 回测验证(统一资金约束):
|
||||
- A股:lab.run_backtest(save=False),取 portfolio_full
|
||||
- 港股:hk_backtest.gen_trades_defensive + lab.portfolio_sim(max_positions=8)
|
||||
- 验收:温区级组合年化 cagr_pct ≥ 原策略 + 3pp 且 max_dd 不劣化超过 2pp
|
||||
5. 达标变体 → 写 strategy_evolution(promoted=0)+ XMPP 推送老莫(附对比证据)
|
||||
(永不自动 promote,老莫说"上线"才进路由)
|
||||
6. 无退化或变体全灭 → 当周静默(不制造噪音)
|
||||
|
||||
单例守卫:fcntl.flock 防并发(deploy_guard / 手动重跑均安全)
|
||||
"""
|
||||
import sys, os, json, sqlite3, copy, io, traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, "/home/hmo/MoFin")
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
sys.path.insert(0, "/home/hmo/MoFin/evolution")
|
||||
|
||||
DB = os.environ.get("MOFIN_DB", "/home/hmo/MoFin/data/mofin.db")
|
||||
WEIGHTS_JSON = "/home/hmo/MoFin/data/strategy_weights.json"
|
||||
|
||||
# 退化信号参数
|
||||
HEALTH_LOW = 40 # 健康度低于此值视为低
|
||||
HEALTH_STREAK_DAYS = 5 # 连续天数
|
||||
REGIME_CAGR_BAD = 0.0 # 温区级组合年化低于此值视为退化
|
||||
|
||||
# 变体生成参数
|
||||
VAR_PCT = 0.20 # ±20% 网格
|
||||
MAX_VARIANTS = 6 # 每策略最多生成变体数
|
||||
MAX_VARIANTS_TEST = 1 # 最多回测验证的变体数(资源约束:单变体2y全市场回测6-8分钟/6-8GB,详见下方BT注释)
|
||||
|
||||
# 验证回测周期(2026-08-15:原5y全市场回测单变体8+分钟/5GB内存,改为2y控制资源;
|
||||
# 验收对比用同周期原策略数据,相对改善仍有效)
|
||||
BT_START = "2024-07-01"
|
||||
BT_END = "2026-07-24"
|
||||
BT_PERIOD_TAG = "2y"
|
||||
|
||||
# 验收门槛(2026-08-15 口径说明:变体与 parent 用同周期 strategy_research 2y 整体组合年化对比,
|
||||
# 相对改善有效;原设计"温区级组合年化"需按温区分段重跑变体,资源过重,整体同口径更务实)
|
||||
ACCEPT_CAGR_PP = 3.0 # 组合年化 ≥ 原 + 3pp
|
||||
ACCEPT_DD_PP = 2.0 # max_dd 不劣化超过 2pp
|
||||
|
||||
|
||||
def log(msg):
|
||||
line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}"
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
# ── 单例守卫(fcntl,Windows 不可用则跳过)──
|
||||
try:
|
||||
import fcntl
|
||||
_LOCK_FD = open("/tmp/evolution_engine.lock", "w")
|
||||
try:
|
||||
fcntl.flock(_LOCK_FD, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("已有 evolution_engine 实例在运行,退出")
|
||||
sys.exit(0)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── 1. 激活策略集合 ──
|
||||
def load_active_strategies():
|
||||
"""返回 [(version, market, regime)]:A股 active + 港股 markets.hk.active"""
|
||||
try:
|
||||
d = json.load(io.open(WEIGHTS_JSON, encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log(f"读 strategy_weights.json 失败: {e}")
|
||||
return []
|
||||
out = []
|
||||
for v in (d.get("active") or []):
|
||||
info = (d.get("weights") or {}).get(v, {})
|
||||
out.append({"version": v, "market": "a",
|
||||
"regime": info.get("best_regime") or info.get("regime") or d.get("state")})
|
||||
hk = (d.get("markets") or {}).get("hk") or {}
|
||||
for v in (hk.get("active") or []):
|
||||
out.append({"version": v, "market": "hk", "regime": hk.get("state")})
|
||||
return out
|
||||
|
||||
|
||||
# ── 2. 退化信号检测 ──
|
||||
def detect_degradation(conn, version, market):
|
||||
"""返回退化原因列表(空=健康)。S1 健康度连续低;S2 温区组合年化<0"""
|
||||
reasons = []
|
||||
|
||||
# S1:健康度连续 5 天 < 40(排除 50 中性=无实盘)
|
||||
rows = conn.execute(
|
||||
"SELECT date, health_score FROM strategy_health WHERE strategy_version=? ORDER BY date DESC LIMIT ?",
|
||||
(version, HEALTH_STREAK_DAYS)).fetchall()
|
||||
if len(rows) >= HEALTH_STREAK_DAYS:
|
||||
scores = [r["health_score"] for r in rows]
|
||||
# 排除"无实盘=50中性"污染:只要连续5天都 < 40 且不是 50 占位
|
||||
if all(s is not None and s < HEALTH_LOW for s in scores) and any(s != 50 for s in scores):
|
||||
reasons.append(f"S1 健康度连续{HEALTH_STREAK_DAYS}天<{HEALTH_LOW}({scores})")
|
||||
|
||||
# S2:适应温区温区级组合年化 < 0(strategy_regime_perf.cagr_pct)
|
||||
r = conn.execute(
|
||||
"SELECT regime, cagr_pct, trades FROM strategy_regime_perf WHERE strategy=? AND market=? ORDER BY trades DESC LIMIT 1",
|
||||
(version, market)).fetchone()
|
||||
if r and r["cagr_pct"] is not None and r["cagr_pct"] < REGIME_CAGR_BAD:
|
||||
reasons.append(f"S2 适应温区[{r['regime']}]组合年化{r['cagr_pct']}%<0({r['trades']}笔)")
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
# ── 3. 变体生成(数据驱动,从实际 config 出发)──
|
||||
_NUM_KEYS = ("tp_pct", "sl_pct", "sl_atr", "max_hold_days", "min_score", "min_momentum",
|
||||
"adx_min", "atr_pct_min", "atr_pct_max", "roc_min", "roc_max",
|
||||
"macd_hist_min", "macd_hist_max", "dist_ma20_min", "vol_ratio_min",
|
||||
"vol_ratio_max", "ma20_slope_max", "mkt_slope_max", "mkt_adx_min",
|
||||
"sector_slope_max", "bias_max", "rsi_max", "ret_max", "mom20_max",
|
||||
"amount_max", "rsi_delta_min", "mkt_rsi_max", "mkt_dd60_max",
|
||||
"mcap_q_max", "pe_q_max", "news3_min", "sec_ret20_max",
|
||||
"pe_q_max", "mcap_q_max", "sec_ret20_min", "bias60_max",
|
||||
"vol_ratio_min", "rsi_delta_min", "bias60_min")
|
||||
_SKIP_KEYS = ("mode", "family", "launch", "version", "name", "summary", "hypothesis",
|
||||
"mkt_mode", "mkt_above_ma20", "hh_only", "hl_only", "sector_above_ma20")
|
||||
|
||||
|
||||
def iter_numeric_fields(node, path=()):
|
||||
"""递归遍历 config,产出 (path_list, field_name, value) 数值字段"""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if k in _SKIP_KEYS:
|
||||
continue
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool) and k in _NUM_KEYS:
|
||||
yield list(path) + [k], k, v
|
||||
elif isinstance(v, dict):
|
||||
yield from iter_numeric_fields(v, list(path) + [k])
|
||||
|
||||
|
||||
def get_parent_cagr(conn, version, market, period_tag=BT_PERIOD_TAG):
|
||||
"""原策略基准:优先同周期 strategy_research(period_tag=2y),无则温区级组合年化(全量)"""
|
||||
if market == "a":
|
||||
r = conn.execute(
|
||||
"SELECT results_json FROM strategy_research WHERE version=? AND period_tag=? "
|
||||
"AND market='a' ORDER BY id DESC LIMIT 1", (version, period_tag)).fetchone()
|
||||
if r:
|
||||
res = json.loads(r["results_json"] or "{}")
|
||||
s = res.get("summary", {})
|
||||
pf = s.get("portfolio_full", {})
|
||||
cagr = pf.get("cagr_pct")
|
||||
dd = pf.get("portfolio_max_dd_pct")
|
||||
if cagr is not None:
|
||||
return cagr, dd
|
||||
# 回退:温区级组合年化(strategy_regime_perf,全量)——仅当同周期数据缺失时
|
||||
r = conn.execute(
|
||||
"SELECT cagr_pct, portfolio_max_dd_pct FROM strategy_regime_perf WHERE strategy=? AND market=? ORDER BY trades DESC LIMIT 1",
|
||||
(version, market)).fetchone()
|
||||
if r:
|
||||
return r["cagr_pct"], r["portfolio_max_dd_pct"]
|
||||
return None, None
|
||||
|
||||
|
||||
def generate_hypothesis_variants(version, market, base_config, period_tag=BT_PERIOD_TAG):
|
||||
"""2026-08-16 数据归纳假设变体:从交易数据归纳可描述条件 → 生成加条件的策略版本
|
||||
假设格式:{feature, direction(max/min), threshold} → 对应入场条件
|
||||
返回 [{version, name, config, change_desc, evidence, hypothesis}]
|
||||
"""
|
||||
try:
|
||||
from hypothesis_miner import induce_hypotheses
|
||||
hs, _ = induce_hypotheses(version, market, period_tag=period_tag)
|
||||
except Exception:
|
||||
hs = []
|
||||
variants = []
|
||||
for h in hs[:MAX_VARIANTS_TEST]:
|
||||
feat = h["feature"]
|
||||
direction = h["direction"]
|
||||
threshold = h["threshold"]
|
||||
# 映射到策略 config 的字段(A股 entry.filters/mr,港股 entry 顶层)
|
||||
cfg = copy.deepcopy(base_config)
|
||||
if market == "hk":
|
||||
entry = cfg.get("entry", {})
|
||||
else:
|
||||
entry = cfg.get("entry", {})
|
||||
# 字段名映射:面板字段 → 策略字段(多数同名,A股 mr 下)
|
||||
key = feat
|
||||
target = entry
|
||||
# A股 config 是 {entry:{filters,mr}} 结构,找可放的位置
|
||||
if market != "hk":
|
||||
if "mr" in entry:
|
||||
target = entry["mr"]
|
||||
elif "filters" in entry:
|
||||
target = entry["filters"]
|
||||
if direction == "max":
|
||||
target[key + "_max"] = threshold
|
||||
else:
|
||||
target[key + "_min"] = threshold
|
||||
vname = f"evo_{version}_{key}_{direction}{threshold}"
|
||||
variants.append({
|
||||
"version": vname,
|
||||
"name": f"自进化-{version}-规避{key}{direction}{threshold}",
|
||||
"config": cfg,
|
||||
"change_desc": f"[数据归纳] {h['hypothesis']}",
|
||||
"evidence": h.get("evidence", ""),
|
||||
"hypothesis": h.get("hypothesis", ""),
|
||||
"field": key,
|
||||
"delta": 0,
|
||||
})
|
||||
return variants
|
||||
|
||||
|
||||
def generate_variants(version, market, config):
|
||||
"""生成变体参数建议:单变量 ±20%,最多 MAX_VARIANTS 个
|
||||
返回 [{version, name, config, change_desc, field, delta}]"""
|
||||
fields = list(iter_numeric_fields(config))
|
||||
if not fields:
|
||||
return []
|
||||
variants = []
|
||||
for path, fname, val in fields:
|
||||
if val <= 0:
|
||||
continue
|
||||
for factor, tag in [(1 - VAR_PCT, "减20%"), (1 + VAR_PCT, "加20%")]:
|
||||
new_val = round(val * factor, 4)
|
||||
if new_val <= 0:
|
||||
continue
|
||||
# 克隆 config 并修改目标字段
|
||||
new_cfg = copy.deepcopy(config)
|
||||
node = new_cfg
|
||||
for p in path[:-1]:
|
||||
node = node[p]
|
||||
node[path[-1]] = new_val
|
||||
variants.append({
|
||||
"version": f"evo_{version}_{fname}_{tag.replace('20%','')}{round(new_val, 2)}",
|
||||
"name": f"自进化-{version}-{fname}{tag}",
|
||||
"config": new_cfg,
|
||||
"change_desc": f"{fname}: {val} → {new_val}({tag})",
|
||||
"field": fname,
|
||||
"delta": round(new_val - val, 4),
|
||||
})
|
||||
if len(variants) >= MAX_VARIANTS:
|
||||
return variants
|
||||
return variants
|
||||
|
||||
|
||||
# ── 4. 回测验证 ──
|
||||
def verify_variant_a(variant, parent_version):
|
||||
"""A股变体验证:注册进 lab 跑回测(save=False),返回 summary 关键指标"""
|
||||
import strategy_lab as lab
|
||||
name = variant["version"]
|
||||
base = lab.get_strategy(parent_version)
|
||||
cfg = copy.deepcopy(base)
|
||||
cfg["version"] = name
|
||||
cfg["name"] = variant["name"]
|
||||
# 用变体 config 覆盖(变体 config 从原 config 克隆并改了一个字段)
|
||||
merged = copy.deepcopy(base["config"])
|
||||
_deep_update(merged, variant["config"])
|
||||
cfg["config"] = merged
|
||||
lab.STRATEGIES[name] = cfg
|
||||
try:
|
||||
r = lab.run_backtest(name, BT_START, BT_END, 913000, save=False,
|
||||
universe="a", period_tag=BT_PERIOD_TAG)
|
||||
s = r.get("summary", {})
|
||||
pf = s.get("portfolio_full", {})
|
||||
return {
|
||||
"trades": s.get("total_trades"),
|
||||
"win_rate": s.get("win_rate"),
|
||||
"cagr": pf.get("cagr_pct"),
|
||||
"total_return": pf.get("total_return_pct"),
|
||||
"max_dd": pf.get("portfolio_max_dd_pct"),
|
||||
}
|
||||
finally:
|
||||
lab.STRATEGIES.pop(name, None)
|
||||
|
||||
|
||||
def _deep_update(dst, src):
|
||||
for k, v in src.items():
|
||||
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
||||
_deep_update(dst[k], v)
|
||||
else:
|
||||
dst[k] = v
|
||||
|
||||
|
||||
def verify_variant_hk(variant, parent_version):
|
||||
"""港股变体验证:hk_backtest 生成交易 + portfolio_sim 8槽"""
|
||||
import pandas as pd
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
from hk_strategies import HK_STRATEGIES, get_hk_strategy
|
||||
import hk_backtest
|
||||
import strategy_lab as lab
|
||||
|
||||
base = get_hk_strategy(parent_version)
|
||||
if not base:
|
||||
return None
|
||||
new_cfg = copy.deepcopy(base)
|
||||
new_cfg["version"] = variant["version"]
|
||||
new_cfg["name"] = variant["name"]
|
||||
_deep_update(new_cfg, variant["config"])
|
||||
panel = hk_backtest.load_panel()
|
||||
# 2y 窗口过滤(与 A股验证周期一致,控制资源)
|
||||
panel = panel[(panel["date"] >= BT_START) & (panel["date"] <= BT_END)].copy()
|
||||
trades = hk_backtest.gen_trades_defensive(panel, new_cfg, strike=3, cooldown_days=15)
|
||||
if not trades:
|
||||
return {"trades": 0, "win_rate": None, "cagr": None, "total_return": None, "max_dd": None}
|
||||
sim = lab.portfolio_sim(trades, 1000000, max_positions=8)
|
||||
return {
|
||||
"trades": len(trades),
|
||||
"win_rate": round(100 * sum(1 for t in trades if t["profit_pct"] > 0) / len(trades), 1),
|
||||
"cagr": sim.get("cagr_pct"),
|
||||
"total_return": sim.get("total_return_pct"),
|
||||
"max_dd": sim.get("portfolio_max_dd_pct"),
|
||||
}
|
||||
|
||||
|
||||
# ── 5. 记录 + 推送 ──
|
||||
def record_and_notify(conn, parent_version, market, variant, result, parent_cagr, parent_dd):
|
||||
"""写 strategy_evolution + XMPP 推送"""
|
||||
conn.execute("""
|
||||
INSERT INTO strategy_evolution (parent_version, child_version, change_description, backtest_result, promoted)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
""", (parent_version, variant["version"], variant["change_desc"],
|
||||
json.dumps(result, ensure_ascii=False)))
|
||||
conn.commit()
|
||||
|
||||
msg = (f"🧬 策略进化建议 [{parent_version}]\n"
|
||||
f"改动: {variant['change_desc']}\n"
|
||||
f"回测: 年化 {parent_cagr}% → {result.get('cagr')}%"
|
||||
f" (Δ{round((result.get('cagr') or 0) - (parent_cagr or 0), 1)}pp)"
|
||||
f" | 回撤 {parent_dd}% → {result.get('max_dd')}%\n"
|
||||
f"胜率 {result.get('win_rate')}% / {result.get('trades')}笔\n"
|
||||
f"【验证达标,待你决定是否上线】")
|
||||
try:
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
from alert_helper import notify, ACTION
|
||||
notify("策略进化", msg, level=ACTION)
|
||||
log(f"XMPP 推送: {parent_version} → {variant['version']}")
|
||||
except Exception as e:
|
||||
log(f"XMPP 推送失败: {e}")
|
||||
return msg
|
||||
|
||||
|
||||
# ── 主流程 ──
|
||||
def run_evolution():
|
||||
conn = get_conn()
|
||||
actives = load_active_strategies()
|
||||
log(f"激活策略: {[a['version'] for a in actives]}")
|
||||
if not actives:
|
||||
log("无激活策略,退出")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
findings = [] # 退化发现
|
||||
passed = [] # 达标变体
|
||||
|
||||
for act in actives:
|
||||
v, mkt = act["version"], act["market"]
|
||||
reasons = detect_degradation(conn, v, mkt)
|
||||
if not reasons:
|
||||
continue
|
||||
log(f"退化信号: {v} [{mkt}] → {'; '.join(reasons)}")
|
||||
findings.append((v, mkt, reasons))
|
||||
|
||||
# 生成变体(A股从 lab 读 config;港股从 HK_STRATEGIES)
|
||||
if mkt == "hk":
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
from hk_strategies import get_hk_strategy
|
||||
base = get_hk_strategy(v)
|
||||
if not base:
|
||||
log(f" {v} 无港股策略定义,跳过")
|
||||
continue
|
||||
# 2026-08-16 优先数据归纳假设,无则参数变体
|
||||
variants = generate_hypothesis_variants(v, mkt, base)
|
||||
if not variants:
|
||||
variants = generate_variants(v, mkt, base)
|
||||
verify_fn = verify_variant_hk
|
||||
else:
|
||||
try:
|
||||
import strategy_lab as lab
|
||||
base = lab.get_strategy(v)
|
||||
except ValueError:
|
||||
log(f" {v} 不在标准回测体系(scanner 类策略),跳过变体研究")
|
||||
continue
|
||||
# 2026-08-16 优先数据归纳假设,无则参数变体
|
||||
variants = generate_hypothesis_variants(v, mkt, base["config"])
|
||||
if not variants:
|
||||
variants = generate_variants(v, mkt, base["config"])
|
||||
verify_fn = verify_variant_a
|
||||
if not variants:
|
||||
log(f" {v} 无可用变体字段,跳过")
|
||||
continue
|
||||
|
||||
parent_cagr, parent_dd = get_parent_cagr(conn, v, mkt)
|
||||
log(f" {v} 原温区年化 {parent_cagr}% / 回撤 {parent_dd}% | 生成 {len(variants)} 个变体,验证前 {MAX_VARIANTS_TEST} 个")
|
||||
for var in variants[:MAX_VARIANTS_TEST]:
|
||||
try:
|
||||
res = verify_fn(var, v)
|
||||
except Exception as e:
|
||||
log(f" {var['version']} 回测失败: {str(e)[:100]}")
|
||||
continue
|
||||
if not res or res.get("cagr") is None:
|
||||
log(f" {var['version']} 无结果(0笔或空),跳过")
|
||||
continue
|
||||
ok_cagr = parent_cagr is None or res["cagr"] >= (parent_cagr or 0) + ACCEPT_CAGR_PP
|
||||
ok_dd = parent_dd is None or res["max_dd"] <= (parent_dd or 0) + ACCEPT_DD_PP
|
||||
status = "✅达标" if (ok_cagr and ok_dd) else "❌不达标"
|
||||
log(f" {var['version']}: 年化 {parent_cagr}→{res['cagr']}% 回撤 {parent_dd}→{res['max_dd']}% [{status}]")
|
||||
if ok_cagr and ok_dd:
|
||||
record_and_notify(conn, v, mkt, var, res, parent_cagr, parent_dd)
|
||||
passed.append((v, var, res))
|
||||
|
||||
conn.close()
|
||||
|
||||
# 汇总
|
||||
if not findings:
|
||||
log("── 无退化信号,当周静默 ──")
|
||||
else:
|
||||
log(f"── 检测 {len(findings)} 个退化策略,{len(passed)} 个达标变体已推送 ──")
|
||||
return findings, passed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
run_evolution()
|
||||
except Exception as e:
|
||||
log(f"evolution_engine 异常: {e}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
evolution/health_monitor.py — 策略健康度监控
|
||||
对比实盘交易 vs 回测预期,计算健康分,偏差过大时报警
|
||||
"""
|
||||
import sys, os, json, sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, '/home/hmo/MoFin')
|
||||
sys.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts')
|
||||
|
||||
DB = os.environ.get('MOFIN_DB', '/home/hmo/MoFin/data/mofin.db')
|
||||
CURRENT_STRATEGY = 'v_next4'
|
||||
|
||||
|
||||
def get_backtest_baseline(conn, version):
|
||||
"""从 strategy_research 取回测基线"""
|
||||
r = conn.execute("""
|
||||
SELECT results_json FROM strategy_research
|
||||
WHERE version=? AND period_tag='5y' ORDER BY id DESC LIMIT 1
|
||||
""", (version,)).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
res = json.loads(r[0])
|
||||
s = res.get('summary', {})
|
||||
return {
|
||||
'win_rate': s.get('win_rate', 0),
|
||||
'avg_profit_pct': s.get('avg_profit_pct', 0),
|
||||
'total_trades': s.get('total_trades', 0),
|
||||
}
|
||||
|
||||
|
||||
def get_live_trades(conn, days=7):
|
||||
"""取近N天实盘交易(holding_strategies 全部,计算盈亏)"""
|
||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT code, name, price as current_price, avg_price as entry_price,
|
||||
timing_signal as signal, updated_at,
|
||||
CASE WHEN avg_price > 0 THEN round((price - avg_price) / avg_price * 100, 2) ELSE 0 END as profit_pct
|
||||
FROM holding_strategies
|
||||
WHERE updated_at >= ? AND avg_price > 0
|
||||
ORDER BY updated_at DESC
|
||||
""", (since,)).fetchall()
|
||||
return rows
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"查询失败: {e}", flush=True)
|
||||
return []
|
||||
|
||||
|
||||
def calc_health_score(live_wr, live_ret, backtest_wr, backtest_ret):
|
||||
"""计算健康分 (0-100)
|
||||
健康分 = 100 - 偏差惩罚
|
||||
偏差 = |实盘胜率-回测胜率| + |实盘收益-回测收益|/2
|
||||
"""
|
||||
if backtest_wr == 0:
|
||||
return 50 # 无基线,中性分
|
||||
|
||||
wr_dev = abs(live_wr - backtest_wr)
|
||||
ret_dev = abs(live_ret - backtest_ret) / 2
|
||||
deviation = wr_dev + ret_dev
|
||||
|
||||
# 偏差越大,健康分越低
|
||||
health = max(0, 100 - deviation * 2)
|
||||
return round(health, 1)
|
||||
|
||||
|
||||
def run_health_check(strategy_version=None):
|
||||
"""执行健康度检查"""
|
||||
version = strategy_version or CURRENT_STRATEGY
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# 回测基线
|
||||
baseline = get_backtest_baseline(conn, version)
|
||||
if not baseline:
|
||||
print(f"无 {version} 回测基线", flush=True)
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 实盘交易(近7天)
|
||||
live = get_live_trades(conn, days=7)
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
if not live:
|
||||
# 无实盘数据,记录中性健康分
|
||||
health = 50
|
||||
deviation = 0
|
||||
live_wr = live_ret = 0
|
||||
print(f"{version}: 近7天无实盘交易,健康分=50(中性)", flush=True)
|
||||
else:
|
||||
wins = sum(1 for t in live if (t.get('profit_pct') or 0) > 0)
|
||||
total = len(live)
|
||||
live_wr = round(100 * wins / total, 1) if total else 0
|
||||
live_ret = round(sum(t.get('profit_pct') or 0 for t in live) / total, 2) if total else 0
|
||||
|
||||
health = calc_health_score(live_wr, live_ret, baseline['win_rate'], baseline['avg_profit_pct'])
|
||||
deviation = abs(live_wr - baseline['win_rate'])
|
||||
|
||||
# 写入 strategy_health 表
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO strategy_health
|
||||
(strategy_version, date, live_trades, live_wins, live_return_pct,
|
||||
backtest_wr, backtest_avg_ret, deviation, health_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (version, today, len(live), sum(1 for t in live if (t.get('profit_pct') or 0) > 0),
|
||||
live_ret, baseline['win_rate'], baseline['avg_profit_pct'], deviation, health))
|
||||
conn.commit()
|
||||
|
||||
# 报警判断(2026-08-12 修:无实盘交易时不告警——health=50 是中性"无数据",非"偏低")
|
||||
alert = None
|
||||
if live:
|
||||
if health < 40:
|
||||
alert = f"🔴 策略健康度严重下降: {health}分 (偏差{deviation}pp)"
|
||||
elif health < 60:
|
||||
alert = f"🟡 策略健康度偏低: {health}分 (偏差{deviation}pp)"
|
||||
|
||||
result = {
|
||||
'version': version,
|
||||
'date': today,
|
||||
'live_trades': len(live),
|
||||
'live_wr': live_wr,
|
||||
'live_ret': live_ret,
|
||||
'backtest_wr': baseline['win_rate'],
|
||||
'backtest_ret': baseline['avg_profit_pct'],
|
||||
'deviation': deviation,
|
||||
'health_score': health,
|
||||
'alert': alert,
|
||||
}
|
||||
|
||||
print(f"{version} 健康度: {health}分 (实盘{live_wr}%/{live_ret}% vs 回测{baseline['win_rate']}%/{baseline['avg_profit_pct']}%)", flush=True)
|
||||
if alert:
|
||||
print(f" {alert}", flush=True)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.path.insert(0, '/home/hmo/MoFin/evolution')
|
||||
from __init__ import init_evolution_tables
|
||||
init_evolution_tables()
|
||||
run_health_check()
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""evolution/hypothesis_miner.py — 数据归纳假设引擎 v2
|
||||
从策略最新交易数据 + 面板特征,归纳可描述的优化假设(方向一核心)
|
||||
数据源:strategy_research trades(code+date)→ 关联 panel_12d 的入场日特征
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import pandas as pd
|
||||
|
||||
# 可归纳特征:panel 字段名 + 标签 + 高值是否坏
|
||||
CANDIDATE_FEATURES = [
|
||||
("mkt_adx", "大盘趋势强度ADX", True),
|
||||
("mkt_rsi", "大盘RSI", True),
|
||||
("mkt_ret20", "大盘近20日涨幅", False),
|
||||
("bias60", "个股60日偏离", True),
|
||||
("rsi", "个股RSI", True),
|
||||
("vol_ratio", "量比", False),
|
||||
("sec_ret20", "行业近20日涨幅", False),
|
||||
]
|
||||
|
||||
_PANEL_CACHE = {} # 模块级缓存:market -> panel(避免每次重载600万行pkl)
|
||||
|
||||
|
||||
def _load_panel(market):
|
||||
"""加载面板:A股 panel_12d.pkl,港股 panel_12d_hk.pkl(带缓存)"""
|
||||
global _PANEL_CACHE
|
||||
if market in _PANEL_CACHE:
|
||||
return _PANEL_CACHE[market]
|
||||
path = "/tmp/panel_12d_hk.pkl" if market == "hk" else "/tmp/panel_12d.pkl"
|
||||
p = pd.read_pickle(path)
|
||||
p = p.sort_values(["code", "date"]).reset_index(drop=True)
|
||||
# 建 code+date → 特征映射
|
||||
p["_key"] = p["code"].astype(str) + "_" + p["date"].astype(str)
|
||||
p = p.drop_duplicates(subset=["_key"])
|
||||
p = p.set_index("_key")
|
||||
_PANEL_CACHE[market] = p
|
||||
return p
|
||||
|
||||
|
||||
def load_trades(version, market, period_tag="2y"):
|
||||
"""从 strategy_research 读 trades,关联面板特征"""
|
||||
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
r = conn.execute(
|
||||
"SELECT results_json FROM strategy_research WHERE version=? AND market=? AND period_tag=? "
|
||||
"ORDER BY id DESC LIMIT 1", (version, market, period_tag)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
return []
|
||||
res = json.loads(r["results_json"] or "{}")
|
||||
trades = res.get("trades", [])
|
||||
try:
|
||||
panel = _load_panel(market)
|
||||
except Exception as e:
|
||||
print(f"panel 加载失败: {e}", flush=True)
|
||||
return []
|
||||
out = []
|
||||
for t in trades:
|
||||
key = str(t.get("code")) + "_" + str(t.get("entry_date"))
|
||||
row = panel.loc[key] if key in panel.index else None
|
||||
out.append({
|
||||
"profit_pct": t.get("profit_pct", 0),
|
||||
"win": t.get("profit_pct", 0) > 0,
|
||||
"hold_days": t.get("hold_days", 0),
|
||||
"exit_reason": t.get("exit_reason", ""),
|
||||
"mkt_adx": row["mkt_adx"] if row is not None and pd.notna(row.get("mkt_adx")) else None,
|
||||
"mkt_rsi": row["mkt_rsi"] if row is not None and pd.notna(row.get("mkt_rsi")) else None,
|
||||
"mkt_ret20": row["mkt_ret20"] if row is not None and pd.notna(row.get("mkt_ret20")) else None,
|
||||
"bias60": row["bias60"] if row is not None and pd.notna(row.get("bias60")) else None,
|
||||
"rsi": row["rsi"] if row is not None and pd.notna(row.get("rsi")) else None,
|
||||
"vol_ratio": row["vol_ratio"] if row is not None and pd.notna(row.get("vol_ratio")) else None,
|
||||
"sec_ret20": row["sec_ret20"] if row is not None and pd.notna(row.get("sec_ret20")) else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _percentile(vals, p):
|
||||
if not vals:
|
||||
return None
|
||||
s = sorted(vals)
|
||||
return s[int((len(s) - 1) * p)]
|
||||
|
||||
|
||||
def induce_hypotheses(version, market, period_tag="2y", min_trades=20, min_effect=15):
|
||||
"""归纳优化假设:找盈利/亏损组的特征差异"""
|
||||
trades = load_trades(version, market, period_tag)
|
||||
if len(trades) < min_trades:
|
||||
return [], trades
|
||||
overall_wr = sum(1 for t in trades if t["win"]) / len(trades) * 100
|
||||
|
||||
hypotheses = []
|
||||
for feat_key, feat_label, high_is_bad in CANDIDATE_FEATURES:
|
||||
vals = [t[feat_key] for t in trades if t.get(feat_key) is not None]
|
||||
if len(vals) < max(5, min_trades * 0.3):
|
||||
continue
|
||||
hi = _percentile(vals, 0.75)
|
||||
lo = _percentile(vals, 0.25)
|
||||
if hi is None or lo is None or hi == lo:
|
||||
continue
|
||||
hi_trades = [t for t in trades if t.get(feat_key) is not None and t[feat_key] >= hi]
|
||||
lo_trades = [t for t in trades if t.get(feat_key) is not None and t[feat_key] <= lo]
|
||||
if len(hi_trades) < 5 or len(lo_trades) < 5:
|
||||
continue
|
||||
hi_wr = sum(1 for t in hi_trades if t["win"]) / len(hi_trades) * 100
|
||||
lo_wr = sum(1 for t in lo_trades if t["win"]) / len(lo_trades) * 100
|
||||
|
||||
if high_is_bad and hi_wr < overall_wr - min_effect:
|
||||
hypotheses.append({
|
||||
"hypothesis": f"当{feat_label}({feat_key})≥{hi:.1f}时胜率仅{hi_wr:.0f}%(整体{overall_wr:.0f}%),应规避",
|
||||
"feature": feat_key, "direction": "max", "threshold": round(hi, 2),
|
||||
"win_rate_affected": round(hi_wr, 1), "win_rate_clean": round(lo_wr, 1),
|
||||
"overall_wr": round(overall_wr, 1),
|
||||
"effect_pp": round(overall_wr - hi_wr, 1),
|
||||
"evidence": f"高分组{len(hi_trades)}笔胜率{hi_wr:.0f}% vs 低分组{len(lo_trades)}笔胜率{lo_wr:.0f}%",
|
||||
"trades_affected": len(hi_trades),
|
||||
})
|
||||
elif not high_is_bad and lo_wr < overall_wr - min_effect:
|
||||
hypotheses.append({
|
||||
"hypothesis": f"当{feat_label}({feat_key})≤{lo:.1f}时胜率仅{lo_wr:.0f}%(整体{overall_wr:.0f}%),应规避",
|
||||
"feature": feat_key, "direction": "min", "threshold": round(lo, 2),
|
||||
"win_rate_affected": round(lo_wr, 1), "win_rate_clean": round(hi_wr, 1),
|
||||
"overall_wr": round(overall_wr, 1),
|
||||
"effect_pp": round(overall_wr - lo_wr, 1),
|
||||
"evidence": f"低分组{len(lo_trades)}笔胜率{lo_wr:.0f}% vs 高分组{len(hi_trades)}笔胜率{hi_wr:.0f}%",
|
||||
"trades_affected": len(lo_trades),
|
||||
})
|
||||
|
||||
hypotheses.sort(key=lambda h: -h["effect_pp"])
|
||||
return hypotheses, trades
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
version = sys.argv[1] if len(sys.argv) > 1 else "v_oversold"
|
||||
market = sys.argv[2] if len(sys.argv) > 2 else "a"
|
||||
hs, trades = induce_hypotheses(version, market)
|
||||
print(f"=== {version} [{market}] {len(trades)}笔 ===")
|
||||
for h in hs:
|
||||
print(f" [Δ{h['effect_pp']:.0f}pp] {h['hypothesis']}")
|
||||
print(f" 证据: {h['evidence']}")
|
||||
if not hs:
|
||||
print(" 未归纳出显著假设")
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
evolution/lesson_extractor.py — 实盘平仓教训提取(2026-08-15 重写)
|
||||
|
||||
旧版病状(见 docs/decisions/2026-08-15-策略自我进化闭环重构.md):
|
||||
- 硬编码 version='v_next4'(已证伪策略)
|
||||
- 名为"已平仓交易教训",实际读的是回测 trades 而非实盘平仓
|
||||
- 用 LLM 逐笔分析回测 trades(既贵又假——回测交易没有"教训"可挖)
|
||||
|
||||
重写方向(设计文档批准):
|
||||
1. 数据源改实盘:strategy_tracking 已平仓记录(status=hit_tp/hit_sl/expired/manual_close)
|
||||
2. 结合当日温区(market_regime)归因
|
||||
3. 规则化提取(非 LLM):命中止盈=盈利规律,止损/超时=亏损教训
|
||||
4. 每周一次,跟随 evolution_engine 同跑(周六 22:00)
|
||||
|
||||
幂等:按 trade_id 去重(同笔不重复写);已写过的 lesson_text 跳过。
|
||||
"""
|
||||
import sys, os, sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, "/home/hmo/MoFin")
|
||||
|
||||
DB = os.environ.get("MOFIN_DB", "/home/hmo/MoFin/data/mofin.db")
|
||||
LOOKBACK_DAYS = 30 # 提取近30天已平仓
|
||||
|
||||
# 状态 → 教训类型映射
|
||||
STATUS_LESSON = {
|
||||
"hit_tp": ("win_pattern", "止盈有效"),
|
||||
"hit_sl": ("loss_pattern", "止损生效"),
|
||||
"expired": ("loss_pattern", "持有到期未达目标"),
|
||||
"manual_close": ("loss_pattern", "人工平仓"),
|
||||
}
|
||||
|
||||
# 平仓原因 → 细化教训
|
||||
REASON_TEXT = {
|
||||
"止盈触发": "触达止盈位落袋",
|
||||
"止损触发": "跌破止损位离场",
|
||||
"反弹减仓触发": "反弹遇阻减仓",
|
||||
"超时退出": "持有超时退出",
|
||||
}
|
||||
|
||||
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_regime_for(conn, date_str, market="a"):
|
||||
"""取指定日期最近的市场温区"""
|
||||
r = conn.execute(
|
||||
"SELECT regime FROM market_regime WHERE market=? AND date<=? ORDER BY date DESC LIMIT 1",
|
||||
(market, date_str)).fetchone()
|
||||
return r["regime"] if r else None
|
||||
|
||||
|
||||
def extract_lessons(days=LOOKBACK_DAYS, verbose=True):
|
||||
"""提取近 N 天实盘已平仓交易的教训"""
|
||||
conn = get_conn()
|
||||
since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT id, code, name, status, closed_at, close_reason, theoretical_pnl,
|
||||
actual_pnl, actual_exit_reason
|
||||
FROM strategy_tracking
|
||||
WHERE status != 'active' AND closed_at >= ?
|
||||
ORDER BY closed_at DESC
|
||||
""", (since,)).fetchall()
|
||||
if not rows:
|
||||
if verbose:
|
||||
print(f"近{days}天无已平仓记录,跳过", flush=True)
|
||||
conn.close()
|
||||
return []
|
||||
|
||||
# 统计 + 提取
|
||||
stats = {"hit_tp": 0, "hit_sl": 0, "expired": 0, "manual_close": 0}
|
||||
lessons = []
|
||||
written = 0
|
||||
for r in rows:
|
||||
status = r["status"]
|
||||
stats[status] = stats.get(status, 0) + 1
|
||||
# 只对止盈/止损提取(expired/manual_close 噪音大,跳过教训提取但统计)
|
||||
if status not in ("hit_tp", "hit_sl"):
|
||||
continue
|
||||
pnl = r["actual_pnl"] if r["actual_pnl"] is not None else r["theoretical_pnl"]
|
||||
if pnl is None:
|
||||
continue
|
||||
# 幂等:同 trade_id 已写过则跳过
|
||||
exist = conn.execute(
|
||||
"SELECT 1 FROM strategy_lessons WHERE trade_id=? AND lesson_type=?",
|
||||
(r["id"], "win_pattern" if status == "hit_tp" else "loss_pattern")).fetchone()
|
||||
if exist:
|
||||
continue
|
||||
|
||||
regime = get_regime_for(conn, (r["closed_at"] or "")[:10])
|
||||
reason_txt = REASON_TEXT.get(r["close_reason"], r["close_reason"] or "平仓")
|
||||
if status == "hit_tp":
|
||||
ltype = "win_pattern"
|
||||
conf = 0.6 if pnl >= 5 else 0.4
|
||||
text = (f"实盘止盈:{r['name']}({r['code']}) {reason_txt},"
|
||||
f"收益{pnl:+.1f}%" + (f"({regime}温区)" if regime else ""))
|
||||
else:
|
||||
ltype = "loss_pattern"
|
||||
conf = 0.6 if pnl <= -5 else 0.4
|
||||
text = (f"实盘止损:{r['name']}({r['code']}) {reason_txt},"
|
||||
f"亏损{pnl:+.1f}%" + (f"({regime}温区)" if regime else ""))
|
||||
lessons.append({
|
||||
"trade_id": r["id"], "lesson_type": ltype, "lesson_text": text,
|
||||
"confidence": conf, "profit_pct": pnl,
|
||||
})
|
||||
|
||||
# 写库
|
||||
for l in lessons:
|
||||
conn.execute("""
|
||||
INSERT INTO strategy_lessons (strategy_version, trade_id, lesson_type, lesson_text, confidence, applied)
|
||||
VALUES ('live_trades', ?, ?, ?, ?, 0)
|
||||
""", (l["trade_id"], l["lesson_type"], l["lesson_text"], l["confidence"]))
|
||||
written += 1
|
||||
conn.commit()
|
||||
|
||||
# 温区级汇总教训(全部已平仓按温区归因)
|
||||
if stats["hit_tp"] + stats["hit_sl"] > 0:
|
||||
tp_pnl = sum((r["actual_pnl"] if r["actual_pnl"] is not None else r["theoretical_pnl"] or 0)
|
||||
for r in rows if r["status"] == "hit_tp")
|
||||
sl_pnl = sum((r["actual_pnl"] if r["actual_pnl"] is not None else r["theoretical_pnl"] or 0)
|
||||
for r in rows if r["status"] == "hit_sl")
|
||||
summary = (f"近{days}天实盘复盘:止盈{stats['hit_tp']}笔(均{round(tp_pnl/max(stats['hit_tp'],1),1)}%)"
|
||||
f" / 止损{stats['hit_sl']}笔(均{round(sl_pnl/max(stats['hit_sl'],1),1)}%)")
|
||||
# 汇总教训写一条(幂等:按文本)
|
||||
exist_sum = conn.execute(
|
||||
"SELECT 1 FROM strategy_lessons WHERE lesson_text=? AND lesson_type='summary'",
|
||||
(summary,)).fetchone()
|
||||
if not exist_sum:
|
||||
conn.execute("""
|
||||
INSERT INTO strategy_lessons (strategy_version, trade_id, lesson_type, lesson_text, confidence, applied)
|
||||
VALUES ('live_trades', NULL, 'summary', ?, 0.8, 0)
|
||||
""", (summary,))
|
||||
written += 1
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
if verbose:
|
||||
print(f"近{days}天已平仓: {stats},新增教训 {written} 条", flush=True)
|
||||
for l in lessons[:5]:
|
||||
print(f" [{l['lesson_type']}] {l['lesson_text']} ({l['confidence']})", flush=True)
|
||||
return lessons
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
extract_lessons()
|
||||
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""evolution/merge_b_group.py — AB融合机制(2026-08-16 方向二闭环)
|
||||
|
||||
老莫:B组候选与A组对照后,融合/合并成为最终实施组(新的A组)。
|
||||
|
||||
流程:
|
||||
1. 读 B 组 verified 候选(data/b_group_candidates.json, status='verified')
|
||||
2. 老莫选择要融合的候选 → 注册为正式策略版本:
|
||||
- A股:写入 strategy_research(results_json 用回测验证的 trades)
|
||||
- 港股:注册进 hk_strategies.py(entry 条件)
|
||||
3. 加入候选池(strategy_weights 路由可识别)
|
||||
4. 手动可用性把关(老莫决定是否启用)——融合≠自动上线
|
||||
|
||||
安全:不自动 promote,不自动启用;融合只是把候选变成"可用的新策略版本"。
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
DATA_DIR = "/home/hmo/MoFin/data"
|
||||
CAND_JSON = f"{DATA_DIR}/b_group_candidates.json"
|
||||
DB = "/home/hmo/MoFin/data/mofin.db"
|
||||
|
||||
|
||||
def load_candidates():
|
||||
try:
|
||||
d = json.load(open(CAND_JSON, encoding="utf-8"))
|
||||
return d.get("candidates", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_verified():
|
||||
return [c for c in load_candidates() if c.get("status") == "verified"]
|
||||
|
||||
|
||||
def strategy_name(cand):
|
||||
"""生成策略版本名:b{regime缩写}{序号}"""
|
||||
rg_map = {"trend_up": "tu", "choppy": "ch", "trend_down": "td"}
|
||||
rg = rg_map.get(cand.get("regime"), "x")
|
||||
idx = cand.get("_idx", 1)
|
||||
return f"b_{rg}{idx}"
|
||||
|
||||
|
||||
def register_a_share(cand):
|
||||
"""A股候选注册:写入 strategy_research(B组候选,供研究Tab/回测)
|
||||
实际回测验证由进化引擎跑,这里先注册占位 + 候选条件记录
|
||||
"""
|
||||
conn = sqlite3.connect(DB, timeout=10)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
name = strategy_name(cand)
|
||||
# 检查是否已注册
|
||||
exist = conn.execute("SELECT 1 FROM strategy_research WHERE version=? LIMIT 1", (name,)).fetchone()
|
||||
if exist:
|
||||
conn.close()
|
||||
return {"status": "exists", "version": name}
|
||||
conn.execute("""
|
||||
INSERT INTO strategy_research (version, name, summary, hypothesis, parent, config_json,
|
||||
results_json, analysis_json, period, created_at, market, period_tag, deprecated)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (name, f"B组-{cand.get('regime','')}", cand.get("hypothesis", ""),
|
||||
"B组融合候选(由果及因挖掘)", "B组", json.dumps(cand.get("entry", {})),
|
||||
json.dumps({"summary": {"total_trades": cand.get("trades_est"),
|
||||
"win_rate": cand.get("sim_win_rate"),
|
||||
"avg_profit_pct": cand.get("sim_avg_pnl")}}),
|
||||
None, None, now, "a", "2y", None))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "registered", "version": name}
|
||||
|
||||
|
||||
def register_hk(cand):
|
||||
"""港股候选注册:追加到 hk_strategies.py"""
|
||||
name = strategy_name(cand)
|
||||
entry = cand.get("entry", {})
|
||||
# 追加到 hk_strategies.py(先读再写)
|
||||
path = "/home/hmo/MoFin/deploy/profile-scripts/hk_strategies.py"
|
||||
src = open(path, encoding="utf-8").read()
|
||||
if f'"{name}"' in src:
|
||||
return {"status": "exists", "version": name}
|
||||
new_block = f'''
|
||||
"{name}": {{
|
||||
"version": "{name}",
|
||||
"name": "B组-{cand.get('regime','')}(由果及因融合)",
|
||||
"regime": "{cand.get('regime','all')}",
|
||||
"summary": "{cand.get('hypothesis','B组候选')[:80]}",
|
||||
"entry": {json.dumps(entry, ensure_ascii=False)},
|
||||
"exit": {{"tp_pct": 0.10, "sl_pct": 0.05, "max_hold_days": 20}},
|
||||
}},
|
||||
}}'''
|
||||
# 在 HK_STRATEGIES 的收尾 "}" 前插入(精确:找最后一个顶层 dict 的收尾)
|
||||
# HK_STRATEGIES 结构:{ "k1": {...}, ..., "kn": {...}, } 然后空行 + get_hk_strategy
|
||||
marker = "\n\n\ndef get_hk_strategy"
|
||||
idx = src.rfind(marker)
|
||||
if idx == -1:
|
||||
return {"status": "error", "version": name, "error": "hk_strategies 结构异常"}
|
||||
insert_at = src.rfind("}", 0, idx)
|
||||
# 去掉 new_block 末尾多余的 }}
|
||||
clean_block = new_block.rstrip()
|
||||
if clean_block.endswith("}}"):
|
||||
clean_block = clean_block[:-1]
|
||||
src = src[:insert_at] + clean_block + src[insert_at:]
|
||||
open(path, "w", encoding="utf-8").write(src)
|
||||
return {"status": "registered", "version": name}
|
||||
|
||||
|
||||
def merge(version=None):
|
||||
"""融合:把 verified 候选注册为策略版本。version 指定要融合的候选,None=全部"""
|
||||
verified = get_verified()
|
||||
if not verified:
|
||||
return {"error": "无 verified B组候选(需先通过模拟验证门槛)", "verified": 0}
|
||||
out = []
|
||||
for i, cand in enumerate(verified):
|
||||
if version and cand.get("version_name") != version:
|
||||
continue
|
||||
cand["_idx"] = i + 1
|
||||
if cand.get("market") == "hk":
|
||||
r = register_hk(cand)
|
||||
else:
|
||||
r = register_a_share(cand)
|
||||
r["candidate"] = cand.get("hypothesis", "")
|
||||
# 融合链路:多周期trades + 温区预计算 + 资格评估 + 可用性初始化
|
||||
if r.get("status") in ("registered", "exists") and r.get("version"):
|
||||
try:
|
||||
link = _post_merge_chain(r["version"], cand)
|
||||
r["chain"] = link
|
||||
except Exception as e:
|
||||
r["chain"] = {"error": str(e)}
|
||||
out.append(r)
|
||||
return {"merged": out}
|
||||
|
||||
|
||||
def _post_merge_chain(version, cand):
|
||||
"""融合后链路:按period_tag生成窗口trades → 温区预计算 → 资格评估 → 可用性
|
||||
返回 {period_trades: {...}, regime_records: n, qualification: {...}, availability: {...}}"""
|
||||
import subprocess, json as _json
|
||||
out = {}
|
||||
# 1) 生成各周期窗口trades 写入 strategy_research(每个 period_tag 记录独立 results_json)
|
||||
# (B组候选的 trades 来自模拟验证,按 entry_date 过滤窗口)
|
||||
try:
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, "/home/hmo/MoFin")
|
||||
_sys.path.insert(0, "/home/hmo/MoFin/evolution")
|
||||
import sqlite3 as _sq
|
||||
import pandas as _pd
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from b_group_miner import _simulate_verify
|
||||
market = cand.get("market", "a")
|
||||
regime = cand.get("regime", "trend_down")
|
||||
entry = cand.get("entry", {})
|
||||
panel_path = "/tmp/panel_12d_hk.pkl" if market == "hk" else "/tmp/panel_12d.pkl"
|
||||
panel = _pd.read_pickle(panel_path)
|
||||
panel = panel.sort_values(["code", "date"]).reset_index(drop=True)
|
||||
panel["fwd_ret60"] = panel.groupby("code")["close"].transform(lambda x: x.shift(-60) / x - 1) * 100
|
||||
cond = _pd.Series(True, index=panel.index)
|
||||
for feat, val in entry.items():
|
||||
if "_min" in feat:
|
||||
cond &= panel[feat.replace("_min", "")] >= val
|
||||
elif "_max" in feat:
|
||||
cond &= panel[feat.replace("_max", "")] < val
|
||||
elif feat in panel.columns:
|
||||
cond &= panel[feat] == val
|
||||
tp = int(cand.get("sim_tp", 15)); sl = int(cand.get("sim_sl", 8)); mh = int(cand.get("sim_maxh", 35))
|
||||
r = _simulate_verify(market, regime, panel, cond, tp=tp, sl=sl, maxh=mh)
|
||||
if not r:
|
||||
out["period_trades"] = {"error": "模拟验证无结果"}
|
||||
else:
|
||||
all_trades = r["trades"]
|
||||
latest_dt = _dt.strptime(max(t["entry_date"] for t in all_trades), "%Y-%m-%d")
|
||||
conn = _sq.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
|
||||
for pt, yrs in [("1y", 1), ("2y", 2), ("5y", 5), ("10y", 10)]:
|
||||
cutoff = (latest_dt - _td(days=365 * yrs)).strftime("%Y-%m-%d")
|
||||
wt = [t for t in all_trades if t["entry_date"] >= cutoff]
|
||||
n = len(wt)
|
||||
wins = [t for t in wt if t.get("profit_pct", 0) > 0]
|
||||
wr = round(len(wins) / n * 100, 1) if n else 0
|
||||
avg = round(sum(t.get("profit_pct", 0) for t in wt) / n, 2) if n else 0
|
||||
results = {"summary": {"total_trades": n, "win_rate": wr, "avg_profit_pct": avg},
|
||||
"trades": wt[:5000],
|
||||
"sim_params": {"tp": tp, "sl": sl, "maxh": mh},
|
||||
"window": {"cutoff": cutoff, "latest": max(t["entry_date"] for t in all_trades)}}
|
||||
conn.execute("UPDATE strategy_research SET results_json=? WHERE version=? AND period_tag=?",
|
||||
(_json.dumps(results, ensure_ascii=False), version, pt))
|
||||
out.setdefault("period_trades", {})[pt] = {"n": n, "win_rate": wr}
|
||||
conn.commit(); conn.close()
|
||||
except Exception as e:
|
||||
out["period_trades"] = {"error": str(e)}
|
||||
# 2) 温区预计算
|
||||
try:
|
||||
mkt_flag = "--market=hk" if market == "hk" else "--market=a"
|
||||
p = subprocess.run(["/home/hmo/MoFin/venv/bin/python",
|
||||
"/home/hmo/MoFin/deploy/profile-scripts/regime_perf_by_period.py",
|
||||
mkt_flag, "--periods=1y 2y 5y 10y"],
|
||||
capture_output=True, text=True, timeout=900)
|
||||
out["regime_run"] = {"rc": p.returncode, "tail": (p.stdout or "").strip().splitlines()[-1:]}
|
||||
except Exception as e:
|
||||
out["regime_run"] = {"error": str(e)}
|
||||
# 3) 资格评估 + 可用性
|
||||
try:
|
||||
_sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
import strategy_qualify as sq
|
||||
out["qualification"] = sq.evaluate_all_regimes(version, market=market)
|
||||
sq.auto_init_availability([version])
|
||||
av = sq.load_availability().get(version)
|
||||
out["availability"] = av
|
||||
except Exception as e:
|
||||
out["qualification"] = {"error": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
v = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
res = merge(v)
|
||||
print(json.dumps(res, ensure_ascii=False, indent=1))
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""evolution/precompute_evolution.py — 进化机制预计算(2026-08-16)
|
||||
定期(每周/每日)预计算进化机制数据,供研究Tab展示(API 只读快照,不实时重算):
|
||||
1. 假设归纳(方向一):每个激活策略的归纳优化假设
|
||||
2. B组候选(方向二):由果及因挖掘的候选
|
||||
3. 策略资格概览
|
||||
输出:data/evolution_center.json
|
||||
"""
|
||||
import sys, os, json
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, "/home/hmo/MoFin")
|
||||
sys.path.insert(0, "/home/hmo/MoFin/evolution")
|
||||
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
|
||||
|
||||
OUT = "/home/hmo/MoFin/data/evolution_center.json"
|
||||
|
||||
|
||||
def active_versions():
|
||||
try:
|
||||
d = json.load(open("/home/hmo/MoFin/data/strategy_weights.json", encoding="utf-8"))
|
||||
vs = list(d.get("active") or [])
|
||||
vs += list(((d.get("markets") or {}).get("hk") or {}).get("active") or [])
|
||||
return list(dict.fromkeys(vs))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
print("=== 进化机制预计算开始 ===", flush=True)
|
||||
from hypothesis_miner import induce_hypotheses
|
||||
from strategy_qualify import evaluate_all_regimes, get_benchmarks
|
||||
|
||||
out = {"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "hypotheses": [], "b_group": [], "qual_overview": []}
|
||||
|
||||
# 1. 假设归纳(方向一)
|
||||
for v in active_versions():
|
||||
mkt = "hk" if v.startswith("hk") else "a"
|
||||
try:
|
||||
hs, _ = induce_hypotheses(v, mkt, period_tag="2y")
|
||||
for h in hs[:3]:
|
||||
out["hypotheses"].append({"strategy": v, "market": mkt, **h})
|
||||
print(f" 假设 [{v}]: {h['hypothesis'][:60]}", flush=True)
|
||||
except Exception as e:
|
||||
print(f" 假设 [{v}] 失败: {e}", flush=True)
|
||||
|
||||
# 2. B组候选(方向二)
|
||||
try:
|
||||
p = json.load(open("/home/hmo/MoFin/data/b_group_candidates.json", encoding="utf-8"))
|
||||
out["b_group"] = p.get("candidates", [])
|
||||
print(f" B组候选: {len(out['b_group'])}", flush=True)
|
||||
except Exception as e:
|
||||
print(f" B组读取失败: {e}", flush=True)
|
||||
|
||||
# 3. 资格概览
|
||||
for v in active_versions():
|
||||
mkt = "hk" if v.startswith("hk") else "a"
|
||||
try:
|
||||
q = evaluate_all_regimes(v, mkt, bench=get_benchmarks(mkt))
|
||||
out["qual_overview"].append({"strategy": v, "market": mkt, "qualification": q})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||
print(f"写入 {OUT}: hypotheses={len(out['hypotheses'])} b_group={len(out['b_group'])} qual={len(out['qual_overview'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
meta_growth.py — 自成长机制的元层
|
||||
|
||||
功能:读取近期 git log,识别修复模式,注入新扫描规则到 hardcode_scanner 的扩展点。
|
||||
让自成长机制本身也会成长——能自动发现新的问题类型并添加对应的扫描规则。
|
||||
|
||||
调度:交易日 12:45 和 00:45(no_agent 模式)
|
||||
- 12:45: 上午盘发现的问题→下午17:25审计就能扫到
|
||||
- 00:45: 全天修复汇总→次日审计带新规则
|
||||
|
||||
输出:/home/hmo/web-dashboard/data/growth_registry.json
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
SCANNER_PATH = "/home/hmo/MoFin/deploy/profile-scripts/hardcode_scanner.py"
|
||||
PROFILE_SCANNER = "/home/hmo/.hermes/profiles/position-analyst/scripts/hardcode_scanner.py"
|
||||
REGISTRY_PATH = "/home/hmo/web-dashboard/data/growth_registry.json"
|
||||
EXTENSION_MARKER = "# 扩展点 — meta_growth 在此追加新规则"
|
||||
|
||||
# 已知问题类别 → 扫描规则模板
|
||||
# meta_growth 分析 git log 后,把新模式匹配到这里生成规则元组
|
||||
PATTERN_TEMPLATES = [
|
||||
{
|
||||
"name": "cash_hardcode",
|
||||
"desc": "现金/金额硬编码",
|
||||
"regex": r"return\s+\d{4,}\b",
|
||||
"reason": "可能的硬编码现金/金额",
|
||||
"git_keywords": ["cash", "现金", "硬编码", "金额", "fallback.*\\d+"],
|
||||
},
|
||||
{
|
||||
"name": "exchange_rate",
|
||||
"desc": "汇率硬编码",
|
||||
"regex": r"0\.8[5-9]\d{1,3}",
|
||||
"reason": "可能的硬编码汇率值",
|
||||
"git_keywords": ["汇率", "rate", "HKD", "CNY", "0.8[5-9]"],
|
||||
},
|
||||
{
|
||||
"name": "lot_size_hardcode",
|
||||
"desc": "港股每手股数硬编码",
|
||||
"regex": r"1手\s*[:=]\s*\d{3,}",
|
||||
"reason": "可能的每手股数硬编码",
|
||||
"git_keywords": ["lot_size", "每手", "手数", "lot", "board lot", "f\\[60\\]"],
|
||||
},
|
||||
{
|
||||
"name": "percent_threshold",
|
||||
"desc": "百分比阈值硬编码",
|
||||
"regex": r"[><=]\s*0\.[0-9]+",
|
||||
"reason": "可能的百分比阈值硬编码",
|
||||
"git_keywords": ["threshold", "阈值", "止损", "stop_loss", "止盈", "百分比"],
|
||||
},
|
||||
{
|
||||
"name": "position_limit",
|
||||
"desc": "仓位金额硬编码",
|
||||
"regex": r"仓位\s*[:=]\s*\d{3,}",
|
||||
"reason": "可能的仓位金额硬编码",
|
||||
"git_keywords": ["仓位", "position", "持仓金额"],
|
||||
},
|
||||
{
|
||||
"name": "hardcoded_path",
|
||||
"desc": "路径硬编码",
|
||||
"regex": r"['\"](?!http|~|\./|\.\./)/home/[^'\"]+['\"]",
|
||||
"reason": "可能的文件路径硬编码(应使用环境变量或配置)",
|
||||
"git_keywords": ["路径", "path", "hardcoded path"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_recent_git_log(hours=8):
|
||||
"""获取最近 N 小时的 git log"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", f"--since={hours} hours ago", "--oneline"],
|
||||
capture_output=True, text=True, cwd="/home/hmo/MoFin", timeout=10
|
||||
)
|
||||
return result.stdout
|
||||
except Exception as e:
|
||||
print(f"[meta_growth] git log 失败: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def analyze_log(log_text):
|
||||
"""分析 git log,识别修复模式"""
|
||||
found_patterns = []
|
||||
lines = log_text.strip().split("\n")
|
||||
|
||||
for tmpl in PATTERN_TEMPLATES:
|
||||
hit_count = 0
|
||||
for line in lines:
|
||||
for kw in tmpl["git_keywords"]:
|
||||
if re.search(kw, line, re.IGNORECASE):
|
||||
hit_count += 1
|
||||
break
|
||||
if hit_count > 0:
|
||||
found_patterns.append({
|
||||
"name": tmpl["name"],
|
||||
"desc": tmpl["desc"],
|
||||
"regex": tmpl["regex"],
|
||||
"reason": tmpl["reason"],
|
||||
"hits": hit_count,
|
||||
})
|
||||
|
||||
return found_patterns
|
||||
|
||||
|
||||
def load_registry():
|
||||
"""加载问题类别注册表"""
|
||||
try:
|
||||
if os.path.exists(REGISTRY_PATH):
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"known_categories": [],
|
||||
"injected_rules": [],
|
||||
"last_run": None,
|
||||
"last_findings": [],
|
||||
}
|
||||
|
||||
|
||||
def save_registry(registry):
|
||||
"""保存注册表"""
|
||||
os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True)
|
||||
with open(REGISTRY_PATH, "w") as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def rule_already_exists(registry, regex):
|
||||
"""检查规则是否已注入"""
|
||||
for r in registry.get("injected_rules", []):
|
||||
if r.get("regex") == regex:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def inject_rule(scanner_path, regex, reason, marker=EXTENSION_MARKER):
|
||||
"""在 hardcode_scanner.py 的扩展点后插入新规则"""
|
||||
if not os.path.exists(scanner_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(scanner_path, "r") as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if regex in content:
|
||||
return False # 已存在
|
||||
|
||||
new_rule = f" (r'{regex}', '{reason}'),\n {marker}"
|
||||
if marker not in content:
|
||||
return False # 没有扩展点
|
||||
|
||||
content = content.replace(marker, new_rule)
|
||||
|
||||
try:
|
||||
with open(scanner_path, "w") as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def self_check():
|
||||
"""自检:检查自成长系统本身的健康度"""
|
||||
issues = []
|
||||
if not os.path.exists(SCANNER_PATH):
|
||||
issues.append("hardcode_scanner.py 不存在")
|
||||
if not os.path.exists(REGISTRY_PATH):
|
||||
issues.append("growth_registry.json 不存在(首次运行正常)")
|
||||
return issues
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.datetime.now().isoformat()
|
||||
period = "afternoon" if datetime.datetime.now().hour < 15 else "overnight"
|
||||
|
||||
# 自检
|
||||
issues = self_check()
|
||||
if issues:
|
||||
for issue in issues:
|
||||
print(f"[meta_growth] ⚠ {issue}", file=sys.stderr)
|
||||
|
||||
# 读取 git log
|
||||
hours = 8 # 过去8小时(覆盖一整个交易时段)
|
||||
log = get_recent_git_log(hours=hours)
|
||||
if not log:
|
||||
print(f"[meta_growth] 无近期提交,跳过")
|
||||
return
|
||||
|
||||
print(f"[meta_growth] 分析 {period} 时段日志 ({len(log.strip().split(chr(10)))} 条提交)")
|
||||
|
||||
# 分析修复模式
|
||||
patterns = analyze_log(log)
|
||||
|
||||
# 加载注册表
|
||||
registry = load_registry()
|
||||
registry["last_run"] = now
|
||||
|
||||
if not patterns:
|
||||
print(f"[meta_growth] 未发现新修复模式")
|
||||
registry["last_findings"] = []
|
||||
save_registry(registry)
|
||||
return
|
||||
|
||||
# 去重注入
|
||||
injected_count = 0
|
||||
for p in patterns:
|
||||
if rule_already_exists(registry, p["regex"]):
|
||||
print(f"[meta_growth] 规则已存在: {p['name']} ({p['regex']})")
|
||||
continue
|
||||
|
||||
# 注入到 MoFin and profile 两个副本
|
||||
ok1 = inject_rule(SCANNER_PATH, p["regex"], p["reason"])
|
||||
ok2 = inject_rule(PROFILE_SCANNER, p["regex"], p["reason"])
|
||||
|
||||
if ok1 or ok2:
|
||||
registry["injected_rules"].append({
|
||||
"name": p["name"],
|
||||
"desc": p["desc"],
|
||||
"regex": p["regex"],
|
||||
"reason": p["reason"],
|
||||
"injected_at": now,
|
||||
"period": period,
|
||||
"hits_in_log": p["hits"],
|
||||
})
|
||||
injected_count += 1
|
||||
print(f"[meta_growth] 注入新规则: {p['name']} ({p['desc']})")
|
||||
|
||||
# 记录到已知类别
|
||||
if p["name"] not in registry["known_categories"]:
|
||||
registry["known_categories"].append(p["name"])
|
||||
|
||||
registry["last_findings"] = patterns
|
||||
save_registry(registry)
|
||||
|
||||
print(f"[meta_growth] 本次注入 {injected_count} 条新规则")
|
||||
if injected_count > 0:
|
||||
print(f"[meta_growth] 下次 hardcode_scanner 运行时将自动使用新规则")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""meta_watchdog.py — L4 自检系统的自检(看门狗的看门狗)
|
||||
|
||||
检查 L1-L3 各自检组件本身是否在正常运转:
|
||||
- L1 functional_health_check: functional_health.json 是否 <20min(交易时段)
|
||||
- L2 system_hygiene_audit: hygiene_report.json 是否 <26h(每日)
|
||||
- L3 self_repair: repair_state.json 存在性 + cron 是否注册
|
||||
- mofin_health 采集: mofin_health.json 是否 <20min(交易时段)
|
||||
- XMPP 桥: :5805 是否可发(self_repair 的报备通道)
|
||||
|
||||
(2026-08-13 删除 L0 agents_health_check 检查项:MoFin 无该组件,且 L1 functional_health_check 已覆盖健康检查功能,检查项是死代码)
|
||||
|
||||
任何一层死了 → 推 XMPP 点名(这是最后的兜底,必须直达用户)。
|
||||
频率:每小时(cron)。输出 gateway/logs/meta_watchdog.json。
|
||||
"""
|
||||
import os, sys, json, subprocess
|
||||
from datetime import datetime
|
||||
|
||||
OUT = '/home/hmo/MoFin/gateway/logs/meta_watchdog.json'
|
||||
|
||||
LAYERS = [
|
||||
{"layer": "L1 functional_health", "file": "/home/hmo/MoFin/gateway/logs/functional_health.json",
|
||||
"max_age_min": 25, "when": "trading",
|
||||
"repair": "L1 cron 停摆,检查 hermes cron 引擎"},
|
||||
{"layer": "L2 hygiene_audit", "file": "/home/hmo/MoFin/gateway/logs/hygiene_report.json",
|
||||
"max_age_min": 26 * 60, "when": "always",
|
||||
"repair": "L2 每日审计未跑,检查 hermes cron"},
|
||||
{"layer": "L1.5 mofin_health采集", "file": "/home/hmo/web-dashboard/static/mofin_health.json",
|
||||
"max_age_min": 25, "when": "trading",
|
||||
"repair": "mofin_health.py 采集停摆"},
|
||||
{"layer": "L3 self_repair", "file": "/home/hmo/MoFin/gateway/logs/repair_log.jsonl",
|
||||
"max_age_min": None, "when": "meta",
|
||||
"repair": "self_repair cron 未注册"},
|
||||
]
|
||||
|
||||
|
||||
def is_trading(now):
|
||||
return now.weekday() < 5 and 9 <= now.hour <= 16
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
trading = is_trading(now)
|
||||
results = []
|
||||
|
||||
for L in LAYERS:
|
||||
if L["when"] == "trading" and not trading:
|
||||
results.append({"layer": L["layer"], "status": "skip", "reason": "非交易时段"})
|
||||
continue
|
||||
if L["when"] == "meta":
|
||||
# 检查 self_repair 是否注册在 cron
|
||||
try:
|
||||
d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'))
|
||||
jobs = d if isinstance(d, list) else d.get('jobs', [])
|
||||
registered = any(j.get('script') == 'self_repair.py' and j.get('enabled', True) for j in jobs)
|
||||
results.append({"layer": L["layer"],
|
||||
"status": "ok" if registered else "fail",
|
||||
"reason": "已注册" if registered else "未在 cron 注册"})
|
||||
except Exception as e:
|
||||
results.append({"layer": L["layer"], "status": "fail", "reason": str(e)[:60]})
|
||||
continue
|
||||
|
||||
f = L["file"]
|
||||
if not os.path.exists(f):
|
||||
results.append({"layer": L["layer"], "status": "fail",
|
||||
"reason": f"输出物不存在", "repair": L["repair"]})
|
||||
continue
|
||||
age_min = (now.timestamp() - os.path.getmtime(f)) / 60
|
||||
if L["max_age_min"] and age_min > L["max_age_min"]:
|
||||
results.append({"layer": L["layer"], "status": "fail",
|
||||
"reason": f"输出物 {age_min/60:.1f}h 未更新(阈值 {L['max_age_min']}min)",
|
||||
"repair": L["repair"]})
|
||||
else:
|
||||
results.append({"layer": L["layer"], "status": "ok",
|
||||
"reason": f"{age_min:.0f}min 前"})
|
||||
|
||||
# XMPP 桥(报备通道):只收 POST,GET 会 501,但任何 HTTP 响应都说明进程活着
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlopen('http://127.0.0.1:5805/', timeout=3)
|
||||
results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": "可达"})
|
||||
except urllib.error.HTTPError as e:
|
||||
results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": f"可达(HTTP {e.code})"})
|
||||
except Exception:
|
||||
results.append({"layer": "XMPP桥 :5805", "status": "fail",
|
||||
"reason": "不可达", "repair": "重启 xmpp-zhiwei"})
|
||||
|
||||
fails = [r for r in results if r["status"] == "fail"]
|
||||
report = {
|
||||
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"status": "fail" if fails else "ok",
|
||||
"layers": results,
|
||||
}
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"meta_watchdog: {report['status']}")
|
||||
for r in results:
|
||||
icon = {"ok": "✅", "fail": "❌", "skip": "⏭"}[r["status"]]
|
||||
print(f" {icon} {r['layer']}: {r['reason']}")
|
||||
|
||||
if fails:
|
||||
try:
|
||||
import urllib.request
|
||||
lines = [f"🚨 自检系统自检(L4兜底)发现 {len(fails)} 层异常:"]
|
||||
for r in fails:
|
||||
lines.append(f"❌ {r['layer']}: {r['reason']}")
|
||||
if r.get('repair'):
|
||||
lines.append(f" → 处置建议: {r['repair']}")
|
||||
payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), 'type': 'chat'}).encode()
|
||||
req = urllib.request.Request('http://127.0.0.1:5805/', data=payload,
|
||||
headers={'Content-Type': 'application/json'})
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
print(' 📨 已推 XMPP(兜底直达)')
|
||||
except Exception as e:
|
||||
print(f' XMPP 失败: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user