feat: v_mr 均值回复策略 v2——10y全市场验证通过(21690笔/53.6%WR/avg+3.34%)
- 独立均值回复引擎: 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 完整策略文档
This commit is contained in:
+250
@@ -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)
|
||||
Reference in New Issue
Block a user