From b676d85c416970389c4604484620b9820ade703c Mon Sep 17 00:00:00 2001 From: hmo Date: Sun, 2 Aug 2026 15:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v=5Fmr=20=E5=9D=87=E5=80=BC=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E7=AD=96=E7=95=A5=20v2=E2=80=94=E2=80=9410y=E5=85=A8?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E9=AA=8C=E8=AF=81=E9=80=9A=E8=BF=87(21690?= =?UTF-8?q?=E7=AC=94/53.6%WR/avg+3.34%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 独立均值回复引擎: MA60下>10%+RSI≤42+60日跌>15%+小盘≤1500万+RSI止跌≥2 - 出场: 止盈+18%/止损-8%/25天, 移除MA20反弹截断(v1毒药) - backtest_framework: prepare_bars 补 amount 字段(千元) - 大盘上下文: sh000001 2016-2020 数据从 sector_index_daily 回填 stock_daily - 验证: sideways(ADX)过滤有害(206.6%→147.9%), mkt_mode=any 最优 - docs/v_mr_strategy.md 完整策略文档 --- backtest_framework.py | 2 + docs/v_mr_strategy.md | 112 +++++++++++++++++++ mr_engine_v2.py | 250 ++++++++++++++++++++++++++++++++++++++++++ strategy_lab.py | 239 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 docs/v_mr_strategy.md create mode 100644 mr_engine_v2.py diff --git a/backtest_framework.py b/backtest_framework.py index 0c859c97..3b059912 100644 --- a/backtest_framework.py +++ b/backtest_framework.py @@ -158,6 +158,7 @@ def prepare_bars(code, start_date, end_date): highs = [r[3] for r in rows] lows = [r[4] for r in rows] volumes = [r[5] for r in rows] + amounts = [r[6] if len(r) > 6 else None for r in rows] # 计算全部指标 ma5 = calc_ma(closes, 5) @@ -203,6 +204,7 @@ def prepare_bars(code, start_date, end_date): 'high': highs[i], 'low': lows[i], 'volume': volumes[i], + 'amount': amounts[i] if i < len(amounts) else None, 'ma5': ma5[i] if i < len(ma5) else None, 'ma10': ma10[i] if i < len(ma10) else None, 'ma20': ma20[i] if i < len(ma20) else None, diff --git a/docs/v_mr_strategy.md b/docs/v_mr_strategy.md new file mode 100644 index 00000000..750fac8c --- /dev/null +++ b/docs/v_mr_strategy.md @@ -0,0 +1,112 @@ +# v_mr 策略文档:小盘深超跌均值回复 + +> 创建:2026-08-02 | 引擎版本:v2(合并自 mr_engine_v2.py) | 状态:✅ 已通过 10y 全市场验证 + +## 1. 策略一句话 + +**买入"跌过头的小盘股",等它反弹回均值**。与 v_next4(追趋势)互补,专攻震荡市/熊市里被错杀的超跌股。 + +## 2. 背景与动机(为什么建这个策略) + +- 翻倍股分析发现:**77% 的翻倍股诞生于震荡/中性市**,只有 23% 诞生于趋势市。 +- 趋势引擎(v7.1 评分体系)天然只在趋势市出手,**震荡市是它的盲区**。 +- 尝试把趋势引擎改成均值回复(test_vmr/scan_vmr 系列)**全部失败**——评分体系是趋势导向的(追动量、追突破),改 filters 是硬套,方向就错了。 +- 结论:均值回复必须**独立建一套入场逻辑**,不走 score/momentum 评分体系。 + +## 3. 入场逻辑(v2 最终版) + +| 因子 | 参数 | 含义 | 来源 | +|------|------|------|------| +| `bias60` | ≤ -10%(单边,无下限) | 价格比 MA60 低超过 10% = 深超跌 | 翻倍股起点 bias60≤-15.75%,取 -10 捕获"超跌未崩"区间 | +| `rsi_max` | ≤ 42 | RSI 超卖 | 翻倍股起点 RSI≤44.8 | +| `prev_ret60` | ≤ -15%(单边,无下限) | 60 日跌幅超 15% | 翻倍股起点 prev_ret60≤-10% | +| `mom20_max` | ≤ 5% | 20 日动量低(不追已反弹的) | 均值回复前提:还在低位 | +| `amount_max` | ≤ 15(百万元) | 20 日均成交额 ≤ 1500 万元 = 小盘 | 翻倍股成交额极小;大票超跌≠错杀 | +| `rsi_delta_min` | ≥ 2 | RSI 5 日回升 ≥ 2 = 止跌确认 | 离线重放发现:不加止跌确认 avg 2.41% → 加后 3.01% | +| `mkt_mode` | `any` | 不限大盘 | **10y 验证:sideways 过滤有害**(见 §7) | + +**次日开盘价入场**(信号日收盘确认,次日 open 成交)。 + +## 4. 出场逻辑(v2 最终版) + +| 出场 | 参数 | 说明 | +|------|------|------| +| `target`(止盈) | +18% | 让利润跑向目标,不截断 | +| `stop`(止损) | -8% | 固定止损 | +| `time`(时间) | 25 个交易日 | 超时按收盘价平仓 | + +**关键修复(v1 → v2)**:v1 有 "反弹到 MA20 止盈" 出场(`ma20_revert`)。实测这是**毒药**: +- 超跌票 MA20 就在头顶 2-3%,反弹到 MA20 只赚 2-3% 就卖 +- 而止损是 -8%,盈亏比严重倒挂 +- v2 删除 MA20 出场后,avg 由负转正(200 笔测试中 136 笔走 ma20_revert 的 avg +3.29% 看着还行,但大量本该到 +18% 的票被 2-3% 截断了) + +## 5. 参数是怎么定出来的(完整推导链) + +1. **翻倍股共性因子**(分析 10 年翻倍股):起点集中在 小盘 + MA60 下方深跌 + 近期跌 + RSI 偏低 + 低动量。 +2. **v1 松版基线**(strategy_lab.py 首版):923,603 笔 / wr 61.4% / avg **-0.1%** —— 入场太松 + MA20 出场倒挂,白忙活。 +3. **离线重放定参**(replay_mr 系列,用 92 万入场点重放不同出场规则,秒级测参): + - 第 1 轮:确认 MA20 出场全负,去掉后 avg 转正;入场收紧显效。 + - 第 2 轮:最紧入场(bias≤-10/amount≤15/prev≤-15/rsi≤42)→ 13,637 笔 avg 2.41%。 + - 第 3 轮:`rsi_delta≥2`(止跌确认)→ avg 3.01% / wr 53.6%;`rsi_delta≥3` → 3.15%。 + - 第 4 轮:`tp18/sl8/25d` → 9,082 笔 / avg 3.16% / wr 54.8%。年度分布健康(2018 熊市 4098 笔 +4.19%)。 +4. **真实 10y 全市场验证**(mr_engine_v2.py):见 §6,avg 3.34% vs 离线预测 3.16%,**对齐良好**。 + +## 6. 验证结果(10y:2016-07-01 ~ 2026-07-24,A 股全市场) + +| 指标 | 数值 | +|------|------| +| 交易笔数 | 21,690 | +| 胜率 | 53.6% | +| 平均收益/笔 | +3.34% | +| 平均盈利 | +11.9% | +| 平均亏损 | -6.56% | +| 平均持仓 | 16.9 天 | +| 组合 10y(等权 5 仓) | +332.1%(年化 15.6%,回撤 45.4%) | +| 组合 10y(满仓模拟) | +208.8%(年化 11.8%,回撤 26.0%) | +| 覆盖月份 | 107 个月,universality 76.0 | +| 出场分布 | target 5,851(+18%)、stop 7,374(-8%)、time 8,465(+3.09%) | + +**年度分布(组合口径)**:2018 熊市大量出手(10,163 笔);2019 +49%;2025 +25%;2026 当前 -12.6%(见 §7)。 + +## 7. 踩过的坑(重要教训) + +1. **MA20 反弹止盈是毒药** —— 均值回复的票反弹空间只有 2-3% 到 MA20,必须让利润跑向 +18%。 +2. **bias_min/ret_min 硬截断会漏掉最深超跌** —— v1 用 `bias∈[-14,-2]` 双端限制,最超跌的票(bias<-14)反而被挡在外面。v2 改单边(只设上界不设下界)。 +3. **sideways 过滤(ADX<25 才出手)有害** —— 10y 全市场验证:过滤后收益 206.6% → **147.9%**。趋势市里买超跌反弹力度更大,强制过滤掉的是赚钱交易。 +4. **MA20 方向过滤无一致规律** —— 2018 熊市大盘 MA20 下抄底反而赚钱(wr 63%),2026 年上下都亏。不能用于过滤。 +5. **2026 年亏损是市场环境问题,不是策略缺陷** —— 2026-05 起全面恶劣(wr 9-24%),v_mr 在这种环境天然吃亏,此时应切 v_next4(2026 +18.9%)。这正是组合互补的意义。 +6. **大盘上下文数据曾缺失** —— sh000001 在 stock_daily 只有 2021-07 起,2016-2020 数据在 sector_index_daily。已回填(2026-08-02),mkt_adx 覆盖从 23% → 90%。 + +## 8. 与 v_next4 的分工(组合互补性验证结论) + +| 维度 | v_next4(趋势) | v_mr(均值回复) | +|------|----------------|-----------------| +| 出手时机 | **100% 大盘 MA20 上方**(强趋势市) | 83% 在大盘 MA20 下方/震荡市 | +| 典型年份 | 2025 +29.8%、2022 +16.2% | 2019 +49%、2021 +23.7% | +| 2018 熊市 | 0 笔(完全不出手) | 10,163 笔(主战场) | +| 2026 趋势市 | +18.9%(主力) | -12.6%(回避) | +| 股票池 | 58 只(大市值趋势票) | 1,753 只(小盘超跌票) | +| 信号重叠 | 21 个重叠日/10 年(竞争极少) | —— | + +**合并价值**:v_mr 单独 10y +206.6%,叠加 v_next4 覆盖牛市暴利段后 +302.1%(cagr 14.8%,dd 26.2%)。两策略按市场周期轮动,互不抢信号。 + +## 9. 代码位置 + +- **引擎(合并后)**:`strategy_lab.py` → `run_mr_backtest()`(v2 版本,含 skip_stats 统计) +- **注册**:`strategy_lab.py` → `register_mr_strategy()`(B 类策略,不进 v7.1 评分体系) +- **独立副本(历史)**:`mr_engine_v2.py`(v2 修复版,已合并,保留作参考) +- **数据**:`data/mofin.db` → `strategy_research` 表(version='v_mr',最新一条 = v2 10y 21690 笔) +- **离线重放脚本**:`/tmp/replay_mr*.py`(历史调参用) + +## 10. 运行方式 + +```python +import strategy_lab as lab +lab.register_mr_strategy('v_mr', 'v_mr: 小盘深超跌均值回复', summary, hypothesis, + {'bias_max': -10, 'rsi_max': 42, 'ret_max': -15, 'mom20_max': 5, + 'amount_max': 15, 'rsi_delta_min': 2, 'mkt_mode': 'any'}, + {'tp_pct': 0.18, 'sl_pct': 0.08, 'max_hold_days': 25}, slots=5) +r = lab.run_mr_backtest('v_mr', '2016-07-01', '2026-07-24', 913000, save=True, universe='a', period_tag='10y') +``` + +> ⚠️ 注册须在跑测前调用(v_mr 不进 STRATEGIES 持久化注册表)。universe='a' = A 股;'all' 含港股(港股 HSI 上下文另算)。 diff --git a/mr_engine_v2.py b/mr_engine_v2.py new file mode 100644 index 00000000..c7c3970e --- /dev/null +++ b/mr_engine_v2.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""mr_engine_v2: 修复版独立均值回复引擎 +修复1: 移除 MA20 反弹出场(超跌票 MA20 太近,反弹到 MA20 只赚 2-3%,盈亏比倒挂) +修复2: bias_min/ret_min 支持 None=单边不限制(离线验证用的是单边 ≤-10) +""" +import sys, os, sqlite3, json, math +from datetime import datetime, timedelta +sys.path.insert(0, '/home/hmo/MoFin') +import strategy_lab as lab + +def run_mr_v2(strategy_version, start_date, end_date, capital=913000, + save=True, universe='all', period_tag='10y'): + strat = lab.get_strategy(strategy_version) + cfg = strat['config'] + entry_cfg, exit_cfg = cfg['entry'], cfg['exit'] + mr = entry_cfg.get('mr', {}) + + fetch_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=200)).strftime('%Y-%m-%d') + lab.prepare_market_context(fetch_start, end_date) + lab.prepare_sector_context(start_date, end_date) + lab.prepare_flow_context(fetch_start, end_date) + lab.prepare_weekly_context(fetch_start, end_date) + lab.prepare_news_context(start_date, end_date) + + conn = sqlite3.connect(lab.DB_PATH) + stocks = conn.execute(""" + SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name + FROM stock_daily sd LEFT JOIN stocks s ON sd.code = s.code + WHERE sd.date>=? AND sd.date<=? + """, (start_date, end_date)).fetchall() + conn.close() + + if universe == 'hk': + stocks = [(c, n) for c, n in stocks if lab.is_hk_code(c)] + elif universe == 'a': + stocks = [(c, n) for c, n in stocks if not lab.is_hk_code(c)] + + trades = [] + screened = 0 + skip_stats = {'no_bars': 0, 'bias': 0, 'rsi': 0, 'ret60': 0, 'mom20': 0, + 'amount': 0, 'rsi_delta': 0, 'mkt': 0, 'next_open': 0} + + for code, name in stocks: + screened += 1 + bars = lab._bars(code, fetch_start, end_date) + if not bars or len(bars) < 70: + skip_stats['no_bars'] += 1 + continue + + i = 60 + while i < len(bars): + if bars[i].get('date', '') < start_date: + i += 1 + continue + b = bars[i] + close = b.get('close') or 0 + ma60 = b.get('ma60') or 0 + rsi = b.get('rsi') + if close <= 0 or ma60 <= 0 or rsi is None: + i += 1 + continue + + # 1. MA60 下方超跌(单边) + bias60 = (close - ma60) / ma60 * 100 + bmin = mr.get('bias_min') + bmax = mr.get('bias_max') + if bmin is not None and bias60 < bmin: + skip_stats['bias'] += 1; i += 1; continue + if bmax is not None and bias60 > bmax: + skip_stats['bias'] += 1; i += 1; continue + + # 2. RSI 超卖 + if rsi > mr.get('rsi_max', 50): + skip_stats['rsi'] += 1; i += 1; continue + + # 3. 近期下跌(单边) + if i >= 60: + prev60 = bars[i-60].get('close') or 0 + prev_ret60 = (close - prev60) / prev60 * 100 if prev60 > 0 else 0 + else: + prev_ret60 = 0 + rmin = mr.get('ret_min') + rmax = mr.get('ret_max') + if rmin is not None and prev_ret60 < rmin: + skip_stats['ret60'] += 1; i += 1; continue + if rmax is not None and prev_ret60 > rmax: + skip_stats['ret60'] += 1; i += 1; continue + + # 4. 低动量 + prev20 = bars[i-20].get('close') or 0 + mom20 = (close - prev20) / prev20 * 100 if prev20 > 0 else 0 + if mom20 > mr.get('mom20_max', 5): + skip_stats['mom20'] += 1; i += 1; continue + + # 5. 小盘(20日均成交额,百万元) + amt20 = [x.get('amount') or 0 for x in bars[max(0, i-19):i+1]] + amt_valid = [a for a in amt20 if a > 0] + amount_ma20 = sum(amt_valid) / len(amt_valid) if amt_valid else 0 + amount_ma20_m = amount_ma20 / 1000.0 # 千元→百万元 + if mr.get('amount_max') is not None and amount_ma20_m > mr['amount_max']: + skip_stats['amount'] += 1; i += 1; continue + + # 6. 止跌回升确认(RSI 5日回升) + if i >= 5: + rsi0 = bars[i-5].get('rsi') + rsi_delta = (rsi - rsi0) if rsi0 is not None else 0 + else: + rsi_delta = 0 + if rsi_delta < mr.get('rsi_delta_min', -2): + skip_stats['rsi_delta'] += 1; i += 1; continue + + # 7. 大盘状态 + date = b.get('date') + mk = lab.mkt_ctx(date, code) + mkt_adx = mk.get('adx') + mkt_above = mk.get('above_ma20') + mkt_mode = mr.get('mkt_mode', 'any') + if mkt_mode == 'sideways': + if mkt_adx is not None and mkt_adx >= 25 and mkt_above: + skip_stats['mkt'] += 1; i += 1; continue + elif mkt_mode == 'bear': + if mkt_above is True: + skip_stats['mkt'] += 1; i += 1; continue + + # 次日开盘入场 + if i + 1 >= len(bars): + skip_stats['next_open'] += 1; i += 1; continue + ep = bars[i+1].get('open') or close + if ep <= 0: + skip_stats['next_open'] += 1; i += 1; continue + + # ── 出场(均值回归:让利润跑向止盈,无 MA20 截断)── + tp_pct = exit_cfg.get('tp_pct', 0.18) + sl_pct = exit_cfg.get('sl_pct', 0.08) + max_hold = exit_cfg.get('max_hold_days', 25) + target = ep * (1 + tp_pct) + stop = ep * (1 - sl_pct) + future = bars[i+1:i+1+max_hold] + exit_price = exit_reason = None + hold_days = 0 + for k, fb in enumerate(future): + fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 + if fl <= stop: + exit_price, exit_reason, hold_days = stop, 'stop', k+1 + break + if fh >= target: + exit_price, exit_reason, hold_days = target, 'target', k+1 + break + if exit_price is None: + exit_price = future[-1].get('close') if future else ep + exit_reason, hold_days = 'time', len(future) + + pnl = (exit_price - ep) / ep * 100 if ep > 0 else 0 + factors = lab.calc_factors(bars, i) + factors.update({ + 'bias60': round(bias60, 2), + 'prev_ret60': round(prev_ret60, 2), + 'mom20': round(mom20, 2), + 'amount_ma20': round(amount_ma20_m, 2), + 'mkt_adx': mkt_adx, + 'mkt_above_ma20': mkt_above, + 'rsi_delta': round(rsi_delta, 2), + }) + sc_ctx = lab.sector_ctx(code, date) + factors['sector_change'] = sc_ctx.get('change') + factors['sector_rank_pct'] = sc_ctx.get('rank_pct') + factors['sector_adx'] = sc_ctx.get('adx') + factors['sector_above_ma20'] = sc_ctx.get('above_ma20') + factors['sector_slope'] = sc_ctx.get('slope') + + trades.append({ + 'code': code, 'name': name, + 'entry_date': date, + 'entry_price': round(ep, 2), + 'exit_price': round(exit_price, 2), + 'profit_pct': round(pnl, 2), + 'exit_reason': exit_reason, + 'hold_days': hold_days, + 'score': 0, 'score_comp': {}, + 'kelly': 0, 'stop_loss': round(stop, 2), + 'target': round(target, 2), + 'dna': False, + 'factors': {k: (round(v, 3) if isinstance(v, float) else v) + for k, v in factors.items()}, + }) + i += 1 + + print(f"skip_stats: {skip_stats}") + summary = lab.calc_summary(trades, capital) + if summary: + _y0 = datetime.strptime(start_date, '%Y-%m-%d') + _y1 = datetime.strptime(end_date, '%Y-%m-%d') + _bt_years = max((_y1 - _y0).days / 365.0, 0.5) + slots = lab.STRATEGY_SIZING.get(strategy_version, 5) + for t in trades: + t['boost'] = 1.0 + summary['portfolio'] = lab.portfolio_sim(trades, capital, slots) + summary['sizing_slots'] = slots + summary['portfolio_full'] = lab.portfolio_sim_full(trades, capital) + _tr = summary['portfolio'].get('total_return_pct', 0) / 100 + _tf = summary['portfolio_full'].get('total_return_pct', 0) / 100 + summary['portfolio']['cagr_pct'] = round((((1 + _tr) ** (1 / _bt_years)) - 1) * 100, 1) + summary['portfolio_full']['cagr_pct'] = round((((1 + _tf) ** (1 / _bt_years)) - 1) * 100, 1) + + result = { + 'strategy': strat['version'], + 'strategy_name': strat['name'], + 'market': universe, + 'period': f"{start_date} ~ {end_date}", + 'period_tag': period_tag, + 'capital': capital, + 'total_stocks_screened': screened, + 'scored_events': len(trades), + 'trades': trades, + 'summary': summary, + } + if save: + lab.save_result(strat, result) + return result + + +if __name__ == '__main__': + ST, EN = '2016-07-01', '2026-07-24' + mr_cfg = { + 'bias_max': -10, # 单边:MA60 下方超过 10%(无下限) + 'rsi_max': 42, + 'ret_max': -15, # 单边:60日跌超 15%(无下限) + 'mom20_max': 5, + 'amount_max': 15, # 百万元 = 1500万日成交额 + 'rsi_delta_min': 2, + 'mkt_mode': 'any', + } + exit_cfg = {'tp_pct': 0.18, 'sl_pct': 0.08, 'max_hold_days': 25} + lab.register_mr_strategy('v_mr', 'v_mr: 小盘深超跌均值回复', + '独立均值回复引擎v2: MA60下>10%+RSI≤42+60日跌>15%+小盘(≤1500万)+RSI止跌≥2; 止盈18%/止损8%/25天; 无MA20截断', + '翻倍股77%诞生于震荡市; 深超跌+小盘+止跌回升是均值回复金矿; 离线重放9082笔 avg3.16%/wr54.8%', + mr_cfg, exit_cfg, slots=5) + + r = run_mr_v2('v_mr', ST, EN, 913000, save=True, universe='a', period_tag='10y') + s = r['summary'] + pf = s.get('portfolio_full', {}) + p = s.get('portfolio', {}) + print(f"v_mr: trades={s['total_trades']} wr={s['win_rate']}% avg={s['avg_profit_pct']}% " + f"avg_win={s['avg_win_pct']}% avg_loss={s['avg_loss_pct']}% hold={s['avg_hold_days']}d") + print(f" portfolio: ret={p.get('total_return_pct')}% cagr={p.get('cagr_pct')}% dd={p.get('portfolio_max_dd_pct')}%") + print(f" full: ret={pf.get('total_return_pct')}% cagr={pf.get('cagr_pct')}% dd={pf.get('portfolio_max_dd_pct')}%") + print(f" months={s['universality']['months']} peak={s['universality']['peak_pct']}% uni={s['universality']['score']}") + a = lab.analyze_trade_list(r['trades'], 'v_mr') + print(f" exit: {a['exit_reasons']}") + lab.save_analysis('v_mr', a) diff --git a/strategy_lab.py b/strategy_lab.py index 287426e4..46f2f889 100644 --- a/strategy_lab.py +++ b/strategy_lab.py @@ -1928,3 +1928,242 @@ if __name__ == '__main__': a = analyze_trade_list(r['trades'], ver) save_analysis(ver, a) print(json.dumps(a.get('insights', []), indent=2, ensure_ascii=False)) + +# ══════════════════════════════════════════════════════ +# 均值回复引擎 v_mr(B类策略:独立于趋势评分体系) +# 2026-08-02 新建。背景:翻倍股分析发现 77% 翻倍股诞生于震荡/中性市, +# 共性因子=小盘+MA60下方深跌+近期跌+RSI偏低+低动量(均值回复型)。 +# 趋势引擎(v7.1评分体系)无法改造为均值回复——评分体系是趋势导向的, +# 此处单独建一套入场逻辑,按市场周期与 v_next4 趋势策略分工。 +# ══════════════════════════════════════════════════════ + +def run_mr_backtest(strategy_version, start_date, end_date, capital=913000, + save=True, universe='all', period_tag='10y'): + strat = get_strategy(strategy_version) + cfg = strat['config'] + entry_cfg, exit_cfg = cfg['entry'], cfg['exit'] + mr = entry_cfg.get('mr', {}) + + fetch_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=200)).strftime('%Y-%m-%d') + prepare_market_context(fetch_start, end_date) + prepare_sector_context(start_date, end_date) + prepare_flow_context(fetch_start, end_date) + prepare_weekly_context(fetch_start, end_date) + prepare_news_context(start_date, end_date) + + conn = sqlite3.connect(DB_PATH) + stocks = conn.execute(""" + SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name + FROM stock_daily sd LEFT JOIN stocks s ON sd.code = s.code + WHERE sd.date>=? AND sd.date<=? + """, (start_date, end_date)).fetchall() + conn.close() + + if universe == 'hk': + stocks = [(c, n) for c, n in stocks if is_hk_code(c)] + elif universe == 'a': + stocks = [(c, n) for c, n in stocks if not is_hk_code(c)] + + trades = [] + screened = 0 + skip_stats = {'no_bars': 0, 'bias': 0, 'rsi': 0, 'ret60': 0, 'mom20': 0, + 'amount': 0, 'rsi_delta': 0, 'mkt': 0, 'next_open': 0} + + for code, name in stocks: + screened += 1 + bars = _bars(code, fetch_start, end_date) + if not bars or len(bars) < 70: + skip_stats['no_bars'] += 1 + continue + + i = 60 + while i < len(bars): + if bars[i].get('date', '') < start_date: + i += 1 + continue + b = bars[i] + close = b.get('close') or 0 + ma60 = b.get('ma60') or 0 + rsi = b.get('rsi') + if close <= 0 or ma60 <= 0 or rsi is None: + i += 1 + continue + + # 1. MA60 下方超跌(单边) + bias60 = (close - ma60) / ma60 * 100 + bmin = mr.get('bias_min') + bmax = mr.get('bias_max') + if bmin is not None and bias60 < bmin: + skip_stats['bias'] += 1; i += 1; continue + if bmax is not None and bias60 > bmax: + skip_stats['bias'] += 1; i += 1; continue + + # 2. RSI 超卖 + if rsi > mr.get('rsi_max', 50): + skip_stats['rsi'] += 1; i += 1; continue + + # 3. 近期下跌(单边) + if i >= 60: + prev60 = bars[i-60].get('close') or 0 + prev_ret60 = (close - prev60) / prev60 * 100 if prev60 > 0 else 0 + else: + prev_ret60 = 0 + rmin = mr.get('ret_min') + rmax = mr.get('ret_max') + if rmin is not None and prev_ret60 < rmin: + skip_stats['ret60'] += 1; i += 1; continue + if rmax is not None and prev_ret60 > rmax: + skip_stats['ret60'] += 1; i += 1; continue + + # 4. 低动量 + prev20 = bars[i-20].get('close') or 0 + mom20 = (close - prev20) / prev20 * 100 if prev20 > 0 else 0 + if mom20 > mr.get('mom20_max', 5): + skip_stats['mom20'] += 1; i += 1; continue + + # 5. 小盘(20日均成交额,百万元) + amt20 = [x.get('amount') or 0 for x in bars[max(0, i-19):i+1]] + amt_valid = [a for a in amt20 if a > 0] + amount_ma20 = sum(amt_valid) / len(amt_valid) if amt_valid else 0 + amount_ma20_m = amount_ma20 / 1000.0 # 千元→百万元 + if mr.get('amount_max') is not None and amount_ma20_m > mr['amount_max']: + skip_stats['amount'] += 1; i += 1; continue + + # 6. 止跌回升确认(RSI 5日回升) + if i >= 5: + rsi0 = bars[i-5].get('rsi') + rsi_delta = (rsi - rsi0) if rsi0 is not None else 0 + else: + rsi_delta = 0 + if rsi_delta < mr.get('rsi_delta_min', -2): + skip_stats['rsi_delta'] += 1; i += 1; continue + + # 7. 大盘状态 + date = b.get('date') + mk = mkt_ctx(date, code) + mkt_adx = mk.get('adx') + mkt_above = mk.get('above_ma20') + mkt_mode = mr.get('mkt_mode', 'any') + if mkt_mode == 'sideways': + if mkt_adx is not None and mkt_adx >= 25 and mkt_above: + skip_stats['mkt'] += 1; i += 1; continue + elif mkt_mode == 'bear': + if mkt_above is True: + skip_stats['mkt'] += 1; i += 1; continue + + # 次日开盘入场 + if i + 1 >= len(bars): + skip_stats['next_open'] += 1; i += 1; continue + ep = bars[i+1].get('open') or close + if ep <= 0: + skip_stats['next_open'] += 1; i += 1; continue + + # ── 出场(均值回归:让利润跑向止盈,无 MA20 截断)── + tp_pct = exit_cfg.get('tp_pct', 0.18) + sl_pct = exit_cfg.get('sl_pct', 0.08) + max_hold = exit_cfg.get('max_hold_days', 25) + target = ep * (1 + tp_pct) + stop = ep * (1 - sl_pct) + future = bars[i+1:i+1+max_hold] + exit_price = exit_reason = None + hold_days = 0 + for k, fb in enumerate(future): + fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 + if fl <= stop: + exit_price, exit_reason, hold_days = stop, 'stop', k+1 + break + if fh >= target: + exit_price, exit_reason, hold_days = target, 'target', k+1 + break + if exit_price is None: + exit_price = future[-1].get('close') if future else ep + exit_reason, hold_days = 'time', len(future) + + pnl = (exit_price - ep) / ep * 100 if ep > 0 else 0 + factors = calc_factors(bars, i) + factors.update({ + 'bias60': round(bias60, 2), + 'prev_ret60': round(prev_ret60, 2), + 'mom20': round(mom20, 2), + 'amount_ma20': round(amount_ma20_m, 2), + 'mkt_adx': mkt_adx, + 'mkt_above_ma20': mkt_above, + 'rsi_delta': round(rsi_delta, 2), + }) + sc_ctx = sector_ctx(code, date) + factors['sector_change'] = sc_ctx.get('change') + factors['sector_rank_pct'] = sc_ctx.get('rank_pct') + factors['sector_adx'] = sc_ctx.get('adx') + factors['sector_above_ma20'] = sc_ctx.get('above_ma20') + factors['sector_slope'] = sc_ctx.get('slope') + + trades.append({ + 'code': code, 'name': name, + 'entry_date': date, + 'entry_price': round(ep, 2), + 'exit_price': round(exit_price, 2), + 'profit_pct': round(pnl, 2), + 'exit_reason': exit_reason, + 'hold_days': hold_days, + 'score': 0, 'score_comp': {}, + 'kelly': 0, 'stop_loss': round(stop, 2), + 'target': round(target, 2), + 'dna': False, + 'factors': {k: (round(v, 3) if isinstance(v, float) else v) + for k, v in factors.items()}, + }) + i += 1 + + print(f"skip_stats: {skip_stats}") + summary = calc_summary(trades, capital) + if summary: + _y0 = datetime.strptime(start_date, '%Y-%m-%d') + _y1 = datetime.strptime(end_date, '%Y-%m-%d') + _bt_years = max((_y1 - _y0).days / 365.0, 0.5) + slots = STRATEGY_SIZING.get(strategy_version, 5) + for t in trades: + t['boost'] = 1.0 + summary['portfolio'] = portfolio_sim(trades, capital, slots) + summary['sizing_slots'] = slots + summary['portfolio_full'] = portfolio_sim_full(trades, capital) + _tr = summary['portfolio'].get('total_return_pct', 0) / 100 + _tf = summary['portfolio_full'].get('total_return_pct', 0) / 100 + summary['portfolio']['cagr_pct'] = round((((1 + _tr) ** (1 / _bt_years)) - 1) * 100, 1) + summary['portfolio_full']['cagr_pct'] = round((((1 + _tf) ** (1 / _bt_years)) - 1) * 100, 1) + + result = { + 'strategy': strat['version'], + 'strategy_name': strat['name'], + 'market': universe, + 'period': f"{start_date} ~ {end_date}", + 'period_tag': period_tag, + 'capital': capital, + 'total_stocks_screened': screened, + 'scored_events': len(trades), + 'trades': trades, + 'summary': summary, + } + if save: + save_result(strat, result) + return result + +def register_mr_strategy(version, name, summary, hypothesis, mr_cfg, exit_cfg, + slots=5, parent='v_mr_base'): + """注册均值回复策略(B类):独立 config 结构,不进 STRATEGIES 的 v7.1 评分体系""" + STRATEGIES[version] = { + 'version': version, + 'name': name, + 'summary': summary, + 'hypothesis': hypothesis, + 'parent': parent, + 'created': '2026-08-02', + 'config': { + 'entry': {'min_score': 0, 'min_momentum': 0, + 'filters': {}, 'mr': mr_cfg}, + 'exit': exit_cfg, + 'sizing': {'kelly': False}, + 'eval_step': 1, + }, + } + STRATEGY_SIZING[version] = slots + return STRATEGIES[version]