diff --git a/strategy_lab.py b/strategy_lab.py index 9ffdb017..b52e321c 100644 --- a/strategy_lab.py +++ b/strategy_lab.py @@ -326,6 +326,50 @@ STRATEGIES.update({ "eval_step": 1, }, }, + # ── 2026-08-11 超跌策略注册(v_weak/p_oversold 回测支持)── + "v_weak": { + "version": "v_weak", + "name": "弱市超跌确认(ADX甜区+6条件)", + "summary": "大盘MA20下+ADX[25,30]甜区 + bias60[-35,-20] + RSI<=25 + 5日急跌 + 距低点近 + 收阳缩量", + "hypothesis": "弱市深超跌反弹,六步方法论+12维框架定稿", + "parent": "v_mr", + "config": { + "entry": {"min_score": 45, "min_momentum": 8, "filters": { + "bias60_min": -35, "bias60_max": -20, + "rsi_max": 25, + "r5f_max": -3, + "dist_lo20_max": 5, + "require_close_up": True, + "vol_shrink_max": 1.0, + "mkt_above_ma20": False, + "mkt_adx_min": 25, "mkt_adx_max": 30, + }}, + "exit": {"tp_pct": 0.30, "sl_pct": 0.12, "max_hold_days": 40}, + "sizing": {"kelly": False}, + "eval_step": 1, + }, + }, + "v_oversold": { + "version": "v_oversold", + "name": "预测超跌反弹(12维因子)", + "summary": "弱市+小市值+低PE+新闻+行业弱+深跌", + "hypothesis": "由果及因:预测超跌反弹", + "parent": "v_weak", + "config": { + "entry": {"min_score": 45, "min_momentum": 8, "filters": { + "mkt_rsi_max": 50, + "mkt_dd60_max": -5, + "mcap_q_max": 0.2, + "pe_q_max": 0.2, + "news3_min": 1, + "sec_ret20_max": 0, + "bias60_max": -20, + }}, + "exit": {"tp_pct": None, "sl_pct": 0.05, "max_hold_days": 40}, + "sizing": {"kelly": False}, + "eval_step": 1, + }, + }, }) @@ -598,6 +642,7 @@ def _load_index_ctx(index_code, start_date, end_date): bars = prepare_bars(index_code, start_date, end_date) if not bars: return ctx + closes = [b.get('close') or 0 for b in bars] for i, b in enumerate(bars): slope = None if i >= 5: @@ -605,14 +650,72 @@ def _load_index_ctx(index_code, start_date, end_date): if m0 and m1: slope = round((m1 - m0) / m0 * 100, 3) ma20 = b.get('ma20') or 0 + # 2026-08-11: mkt_dd60 大盘距60日高点回撤 + mkt_rsi(p_oversold 门控) + hi60 = max(closes[max(0, i-59):i+1]) if closes else 0 + mkt_dd60 = round(((b.get('close') or 0) - hi60) / hi60 * 100, 2) if hi60 > 0 else None ctx[b['date']] = { 'above_ma20': (b.get('close') or 0) > ma20 if ma20 > 0 else None, 'ma20_slope': slope, 'roc': b.get('roc'), 'adx': b.get('adx'), # 趋势强度(choppy市<20,趋势市>25,2026-07-30强化趋势过滤) + 'rsi': b.get('rsi'), # 2026-08-11: 大盘RSI(p_oversold 门控) + 'mkt_dd60': mkt_dd60, # 2026-08-11: 大盘距60日高点回撤 } return ctx +# ── 2026-08-11 外部因子缓存(基本面/新闻/行业动量,回测超跌策略用)── +_EXTERNAL_CACHE = {} + +def _days_ago(dt, n): + """返回 dt 前 n 天的日期字符串""" + from datetime import datetime, timedelta + try: + return (datetime.strptime(dt, '%Y-%m-%d') - timedelta(days=n)).strftime('%Y-%m-%d') + except: + return dt + +def _get_external_factors(code, dt): + """惰性查询 + 缓存:返回该 code 在 dt 日的外部因子(mcap_q/pe_q/news3/sec_ret20)""" + key = (code, dt) + if key in _EXTERNAL_CACHE: + return _EXTERNAL_CACHE[key] + result = {} + try: + # 行业动量 sec_ret20(sector_index_daily 20日涨跌) + _srow = conn.execute("SELECT sector_name FROM stock_sectors WHERE code=? LIMIT 1", (code,)).fetchone() + if _srow and _srow[0]: + _sector = _srow[0] + _prev = conn.execute( + "SELECT close FROM sector_index_daily WHERE sector=? AND date<=? ORDER BY date DESC LIMIT 21", + (_sector, dt)).fetchall() + if len(_prev) >= 20 and _prev[-1][0] and _prev[-1][0] > 0: + result['sec_ret20'] = round((_prev[0][0] - _prev[-1][0]) / _prev[-1][0] * 100, 2) + # 新闻 3 日计数 news3 + _nrow = conn.execute( + "SELECT COUNT(*) FROM stock_news WHERE code=? AND date>? AND date<=?", + (code, _days_ago(dt, 3), dt)).fetchone() + if _nrow: + result['news3'] = _nrow[0] + # 基本面分位 mcap_q/pe_q + _frow = conn.execute( + "SELECT mcap_total, pe FROM stock_fundamentals WHERE code=?", (code,)).fetchone() + if _frow and _frow[0]: + _mcap = _frow[0] + _t = conn.execute("SELECT COUNT(*) FROM stock_fundamentals WHERE mcap_total>0 AND mcap_total0").fetchone()[0] + if _tot: + result['mcap_q'] = round(_t / _tot, 3) + if _frow and _frow[1]: + _pe = _frow[1] + _t = conn.execute("SELECT COUNT(*) FROM stock_fundamentals WHERE pe>0 AND pe0").fetchone()[0] + if _tot: + result['pe_q'] = round(_t / _tot, 3) + except Exception: + pass + _EXTERNAL_CACHE[key] = result + return result + def is_hk_code(code): return len(code) == 5 and code.startswith('0') @@ -951,6 +1054,20 @@ def pass_filters(factors, filters): if filters.get('weekly_aligned') and factors.get('weekly_aligned') is not True: return False if filters.get('weekly_down_only') and factors.get('weekly_up') is not False: return False if not chk('weekly_dist', filters.get('weekly_dist_min'), filters.get('weekly_dist_max')): return False + # ── 2026-08-11 超跌/大盘RSI/基本面/新闻因子(v_weak/p_oversold 回测支持)── + if not chk('bias60', filters.get('bias60_min'), filters.get('bias60_max')): return False + if not chk('dist_lo20', filters.get('dist_lo20_min'), filters.get('dist_lo20_max')): return False + if not chk('r5f', filters.get('r5f_min'), filters.get('r5f_max')): return False + if not chk('vol_shrink', filters.get('vol_shrink_min'), filters.get('vol_shrink_max')): return False + if filters.get('require_close_up') and not factors.get('close_up'): return False + # 大盘因子(mkt_rsi/mkt_dd60) + if not chk('mkt_rsi', filters.get('mkt_rsi_min'), filters.get('mkt_rsi_max')): return False + if not chk('mkt_dd60', filters.get('mkt_dd60_min'), filters.get('mkt_dd60_max')): return False + # 基本面/新闻因子(mcap_q/pe_q/news3/sec_ret20) + if not chk('mcap_q', filters.get('mcap_q_min'), filters.get('mcap_q_max')): return False + if not chk('pe_q', filters.get('pe_q_min'), filters.get('pe_q_max')): return False + if not chk('news3', filters.get('news3_min'), filters.get('news3_max')): return False + if not chk('sec_ret20', filters.get('sec_ret20_min'), filters.get('sec_ret20_max')): return False return True @@ -996,6 +1113,20 @@ def calc_factors(bars, idx): l10 = min(x.get('low') or 1e9 for x in bars[idx-9:idx-4]) f['hh_structure'] = h5 > h10 # 更高的高点 = 上升结构 f['hl_structure'] = l5 > l10 # 更高的低点 = 上升结构 + # ── 2026-08-11 超跌因子(v_weak/p_oversold 回测支持)── + ma60 = b.get('ma60') or 0 + f['bias60'] = round((close - ma60) / ma60 * 100, 2) if ma60 > 0 else None + if idx >= 19: + lo20 = min((x.get('low') or 1e9) for x in bars[idx-19:idx+1]) + f['dist_lo20'] = round((close - lo20) / lo20 * 100, 2) if lo20 and lo20 > 0 else None + if idx >= 5: + prev5c = bars[idx-5].get('close') or 0 + f['r5f'] = round((close - prev5c) / prev5c * 100, 2) if prev5c > 0 else None + if idx >= 19: + vol5 = sum((x.get('volume') or 0) for x in bars[idx-4:idx+1]) / 5 + vol20 = sum((x.get('volume') or 0) for x in bars[idx-19:idx+1]) / 20 + f['vol_shrink'] = round(vol5 / vol20, 2) if vol20 > 0 else None + f['close_up'] = close > (bars[idx-1].get('close') or 0) if idx >= 1 else None return f @@ -1097,6 +1228,12 @@ def run_backtest(strategy_version, start_date, end_date, capital=913000, save=Tr factors.update(fl) # 周线因子 factors.update(weekly_ctx(code, date)) + # ── 2026-08-11 外部因子注入(超跌策略回测支持)── + factors['mkt_rsi'] = mk.get('rsi') + factors['mkt_dd60'] = mk.get('mkt_dd60') + _lazy = _get_external_factors(code, date) + if _lazy: + factors.update(_lazy) if pass_filters(factors, filters): # ── 次日开盘价入场(杜绝"信号日收盘买"的未来幻觉)──