#!/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)