From 4f6c8cad4d24d5c41b4636c4875422abd66864b7 Mon Sep 17 00:00:00 2001 From: xxm Date: Sat, 15 Aug 2026 18:26:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AD=96=E7=95=A5=E8=87=AA=E6=88=91?= =?UTF-8?q?=E8=BF=9B=E5=8C=96=E9=97=AD=E7=8E=AF=E8=90=BD=E5=9C=B0=E2=80=94?= =?UTF-8?q?=E2=80=94evolution=5Fengine=E6=96=B0=E5=BB=BA(=E9=80=80?= =?UTF-8?q?=E5=8C=96=E6=A3=80=E6=B5=8B+=E6=95=B0=E6=8D=AE=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E5=8F=98=E4=BD=93=C2=B120%+2y=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E9=AA=8C=E8=AF=81+=E8=BE=BE=E6=A0=87=E6=89=8D=E6=8E=A8?= =?UTF-8?q?=E9=80=81)+lesson=5Fextractor=E9=87=8D=E5=86=99(=E5=AE=9E?= =?UTF-8?q?=E7=9B=98=E5=B9=B3=E4=BB=93+=E6=B8=A9=E5=8C=BA=E5=BD=92?= =?UTF-8?q?=E5=9B=A0)+auto=5Fiterator=E5=BD=92=E6=A1=A3+cron=E6=AF=8F?= =?UTF-8?q?=E5=91=A8=E5=85=AD22:00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- archive/20260815-evolution-rewrite/README.md | 10 + .../auto_iterator.py | 0 deploy/profile-scripts/evolution_daily.py | 29 ++ evolution/evolution_engine.py | 383 ++++++++++++++++++ evolution/lesson_extractor.py | 228 ++++++----- 5 files changed, 558 insertions(+), 92 deletions(-) create mode 100644 archive/20260815-evolution-rewrite/README.md rename {evolution => archive/20260815-evolution-rewrite}/auto_iterator.py (100%) create mode 100644 deploy/profile-scripts/evolution_daily.py create mode 100644 evolution/evolution_engine.py diff --git a/archive/20260815-evolution-rewrite/README.md b/archive/20260815-evolution-rewrite/README.md new file mode 100644 index 00000000..45e3c3b4 --- /dev/null +++ b/archive/20260815-evolution-rewrite/README.md @@ -0,0 +1,10 @@ +# 2026-08-15 进化模块重写 + +## auto_iterator.py 已退役 +- 病状:参数空间(max_hold_days/reentry_days/sl_atr)是旧家族参数,与当前温区策略脱节 +- 取代:evolution/evolution_engine.py(数据驱动变体生成,从实际config出发±20%单变量) +- 归档日期:2026-08-15 + +## 新架构 +- evolution/evolution_engine.py — 退化检测+变体生成+回测验证+推送(每周六22:00 cron) +- evolution/lesson_extractor.py — 实盘平仓教训提取(重写,替代读回测trades的旧版) diff --git a/evolution/auto_iterator.py b/archive/20260815-evolution-rewrite/auto_iterator.py similarity index 100% rename from evolution/auto_iterator.py rename to archive/20260815-evolution-rewrite/auto_iterator.py diff --git a/deploy/profile-scripts/evolution_daily.py b/deploy/profile-scripts/evolution_daily.py new file mode 100644 index 00000000..c03ff038 --- /dev/null +++ b/deploy/profile-scripts/evolution_daily.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""evolution_daily.py — 策略进化引擎 cron 入口(每周六 22:00,hermes cron) + +放在 deploy/profile-scripts(硬链接进 cron scripts 目录), +内部调用 evolution/evolution_engine.py(真正逻辑所在,含单例守卫)。 + +设计依据:docs/decisions/2026-08-15-策略自我进化闭环重构.md +- 每周六 22:00 运行(策略评估 21:00 之后) +- 无退化/变体不达标 → 静默 +- 达标变体 → strategy_evolution + XMPP 推送老莫(永不自动 promote) +""" +import subprocess, sys, os + +REPO = "/home/hmo/MoFin" +ENGINE = f"{REPO}/evolution/evolution_engine.py" +PY = f"{REPO}/venv/bin/python" + + +def main(): + if not os.path.exists(ENGINE): + print(f"evolution_engine.py 不存在: {ENGINE}", flush=True) + sys.exit(1) + # 单例守卫在 engine 内部(fcntl),这里直接调用 + r = subprocess.run([PY, ENGINE], cwd=REPO, capture_output=False, timeout=3600) + sys.exit(r.returncode) + + +if __name__ == "__main__": + main() diff --git a/evolution/evolution_engine.py b/evolution/evolution_engine.py new file mode 100644 index 00000000..b673f62e --- /dev/null +++ b/evolution/evolution_engine.py @@ -0,0 +1,383 @@ +# -*- 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") + +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_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 + 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 + 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) diff --git a/evolution/lesson_extractor.py b/evolution/lesson_extractor.py index 7de562c9..2f97c7e3 100644 --- a/evolution/lesson_extractor.py +++ b/evolution/lesson_extractor.py @@ -1,106 +1,150 @@ +# -*- coding: utf-8 -*- """ -evolution/lesson_extractor.py — 交易后教训提取 -分析已平仓交易,用LLM提取"为什么赢/亏"的教训 +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, json, sqlite3 +import sys, os, sqlite3 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") -DB = os.environ.get('MOFIN_DB', '/home/hmo/MoFin/data/mofin.db') +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_closed_trades(conn, days=30): - """取近N天已平仓的交易(从strategy_research的trades里取最近的,或从holding_strategies推断)""" - # 从最近的回测结果取交易(作为样本分析) - r = conn.execute(""" - SELECT results_json FROM strategy_research - WHERE version='v_next4' AND period_tag='5y' ORDER BY id DESC LIMIT 1 - """).fetchone() - if not r: +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 [] - res = json.loads(r[0]) - trades = res.get('trades', []) - # 按日期排序,取最近的 - recent = sorted(trades, key=lambda x: x.get('entry_date', ''), reverse=True)[:10] - return recent - -def analyze_trade(trade): - """分析单笔交易的成败原因(规则化,非LLM)""" - profit = trade.get('profit_pct', 0) - hold_days = trade.get('hold_days', 0) - factors = trade.get('factors', {}) - + # 统计 + 提取 + stats = {"hit_tp": 0, "hit_sl": 0, "expired": 0, "manual_close": 0} lessons = [] - - # 盈利交易的共性 - if profit > 15: - if factors.get('mkt_adx', 0) > 25: - lessons.append(('win_pattern', f"大盘趋势强(ADX={factors['mkt_adx']:.0f})时盈利{profit:.1f}%", 0.8)) - if factors.get('sector_above_ma20'): - lessons.append(('win_pattern', f"板块在MA20上方时盈利{profit:.1f}%", 0.7)) - if trade.get('dna'): - lessons.append(('win_pattern', f"动量基因(DNA)票盈利{profit:.1f}%", 0.9)) - - # 亏损交易的共性 - if profit < -5: - if factors.get('mkt_slope', 0) < -0.5: - lessons.append(('loss_pattern', f"大盘斜率负({factors['mkt_slope']:.2f})时亏损{profit:.1f}%", 0.7)) - if not factors.get('sector_above_ma20'): - lessons.append(('loss_pattern', f"板块在MA20下方时亏损{profit:.1f}%", 0.6)) - if hold_days < 5: - lessons.append(('loss_pattern', f"持仓{hold_days}天短于5天时亏损{profit:.1f}%", 0.5)) - - # 长持盈利 - if profit > 10 and hold_days > 30: - lessons.append(('win_pattern', f"长持{hold_days}天盈利{profit:.1f}%", 0.85)) - + 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 -def extract_lessons(days=30): - """提取近N天交易的教训""" - conn = sqlite3.connect(DB) - conn.row_factory = sqlite3.Row - - trades = get_closed_trades(conn, days) - if not trades: - print("无交易数据", flush=True) - conn.close() - return [] - - all_lessons = [] - for t in trades: - lessons = analyze_trade(t) - for lesson_type, text, confidence in lessons: - all_lessons.append({ - 'strategy_version': 'v_next4', - 'trade_id': t.get('id', 0), - 'lesson_type': lesson_type, - 'lesson_text': text, - 'confidence': confidence, - 'profit_pct': t.get('profit_pct', 0), - 'entry_date': t.get('entry_date', ''), - }) - - # 写入 strategy_lessons 表 - for l in all_lessons: - conn.execute(""" - INSERT INTO strategy_lessons (strategy_version, trade_id, lesson_type, lesson_text, confidence, applied) - VALUES (?, ?, ?, ?, ?, 0) - """, (l['strategy_version'], l['trade_id'], l['lesson_type'], l['lesson_text'], l['confidence'])) - conn.commit() - - # 打印 - print(f"分析 {len(trades)} 笔交易, 提取 {len(all_lessons)} 条教训", flush=True) - for l in all_lessons[:5]: - print(f" [{l['lesson_type']}] {l['lesson_text']} (置信度{l['confidence']})", flush=True) - - conn.close() - return all_lessons - - -if __name__ == '__main__': +if __name__ == "__main__": extract_lessons()