diff --git a/strategy_lab.py b/strategy_lab.py index 95916f11..7d4ef765 100644 --- a/strategy_lab.py +++ b/strategy_lab.py @@ -515,9 +515,9 @@ STRATEGIES.update({ "v_next3": _v81_conviction("v_next3", "v8.1+信念叠乘+行业牛杠杆", "v8.1波段基座; DNA×2 + 行业ADX>25×2 + flow_delta>0×2 叠乘, 封顶×4", "多因子共振的票才是极品配最重仓; 行业ADX>25=行业级确认牛, 比>=20更精准", - {"model": "叠乘封顶×4: DNA×2 + 行业ADX>25×2 + flow_delta>0×2", + {"model": "叠乘封顶×4: DNA×2 + 行业ADX>25×2 + flow_delta>0×2 + 新闻正向×2", "stack": True, "cap": 4.0, "dna_mult": 2.0, "sector_adx_min": 25, "sector_mult": 2.0, - "flow_delta_min": 0, "flow_mult": 2.0}), + "flow_delta_min": 0, "flow_mult": 2.0, "news_mult": 2.0, "news_days": 3}), }) STRATEGY_DESCRIPTIONS.update({ @@ -528,7 +528,7 @@ STRATEGY_DESCRIPTIONS.update({ "evidence": "5年全参与+48.3%(年化8.8%)/回撤4.1%。×2票69%胜率/+14.23% vs ×1票48%/+5.99%。规则经逆向验证103/103笔精确复现(平铺×2)。", }, "v_next3": { - "title": "v8.1+信念叠乘+行业牛杠杆(DNA×2·行业ADX>25×2·资金加速度×2, 封顶×4)【当前最优】", + "title": "v8.1+信念叠乘+行业牛杠杆+新闻因子(DNA×2·行业ADX>25×2·资金加速度×2·新闻正向×2, 封顶×4)【当前最优】", "algorithm": "v8.1波段出场基座 + 信念因子叠乘重仓: 动量基因DNA×2、行业确认牛(行业ADX>25)×2、资金加速度(flow_delta>0)×2; 多因子同时命中则叠乘, 封顶×4; 均不命中×1。基准3仓, 单票最大4倍基准仓。", "rationale": "v_next平铺只区分'有没有信念', 叠乘区分'信念有多强'——多因子共振的票是极品, 配最重仓位。行业ADX>25是行业级确认牛(比≥20更严格), 对应生产端行业牛杠杆(ADX>25升一档仓位)。", "evidence": "5年103笔/57.3%胜率, 全参与+55.6%(年化9.1%)/回撤4.1%, 收益/回撤比12.4全场最优。boost分布×1:56笔/×2:37笔/×4:10笔。规则经逆向验证102/103笔精确复现。", @@ -744,6 +744,87 @@ def flow_ctx(code, date, bars_dates, idx): # ══════════════════════════════════════════════════════ _WEEKLY_CTX = {} # code -> sorted [(date, close, ma10w, ma20w)] +# ══════════════════════════════════════════════════════ +# 新闻情绪上下文(stock_news 表,同花顺+东财) +# ══════════════════════════════════════════════════════ +_NEWS_CTX = {} # code -> sorted [(date_str, sentiment)] +_NEWS_DATES = [] # sorted dates +_SECTOR_NEWS_CTX = {} # sector -> sorted [(date_str, sentiment)] + +def prepare_news_context(start_date, end_date): + """加载个股新闻情绪(关键词分类)""" + global _NEWS_CTX, _NEWS_DATES, _SECTOR_NEWS_CTX + _NEWS_CTX, _SECTOR_NEWS_CTX = {}, {} + conn = sqlite3.connect(DB_PATH) + try: + rows = conn.execute(""" + SELECT code, SUBSTR(date,1,10) as d, title, content + FROM stock_news WHERE date >= ? AND date <= ? + ORDER BY code, date + """, (start_date, end_date)).fetchall() + except sqlite3.OperationalError: + rows = [] + finally: + conn.close() + + POS = ['涨停','大涨','预增','中标','增持','回购','利好','创新高','突破','净买入','资金流入','机构买入','看多','上调','回暖','复苏','放量'] + NEG = ['跌停','大跌','预亏','减持','亏损','利空','立案','风险','下调','净卖出','资金流出','机构卖出','看空','退市','下滑','萎缩'] + + from collections import defaultdict + by_code = defaultdict(list) + for code, d, title, content in rows: + text = (title or '') + ' ' + (content or '') + pos = sum(1 for kw in POS if kw in text) + neg = sum(1 for kw in NEG if kw in text) + if pos > neg: + sent = 'positive' + elif neg > pos: + sent = 'negative' + else: + continue # 中性不存 + by_code[code].append((d, sent)) + + for code, lst in by_code.items(): + _NEWS_CTX[code] = lst + + # 行业聚合 + try: + conn = sqlite3.connect(DB_PATH) + sector_map = dict(conn.execute("SELECT code, sector FROM stock_sectors_em").fetchall()) + conn.close() + sector_news = defaultdict(list) + for code, lst in by_code.items(): + sec = sector_map.get(code) + if sec: + for d, sent in lst: + sector_news[sec].append((d, sent)) + for sec, lst in sector_news.items(): + _SECTOR_NEWS_CTX[sec] = sorted(lst) + except: + pass + + _NEWS_DATES = sorted({d for lst in by_code.values() for d, _ in lst}) + +def news_ctx(code, date, days=3): + """返回个股近N日新闻情绪统计""" + from datetime import datetime, timedelta + d_end = date + d_start = (datetime.strptime(date, '%Y-%m-%d') - timedelta(days=days)).strftime('%Y-%m-%d') + lst = _NEWS_CTX.get(code, []) + pos = sum(1 for d, s in lst if d_start <= d < d_end and s == 'positive') + neg = sum(1 for d, s in lst if d_start <= d < d_end and s == 'negative') + return {'pos': pos, 'neg': neg, 'total': pos + neg} + +def sector_news_ctx(sector, date, days=3): + """返回行业近N日新闻情绪统计""" + from datetime import datetime, timedelta + d_end = date + d_start = (datetime.strptime(date, '%Y-%m-%d') - timedelta(days=days)).strftime('%Y-%m-%d') + lst = _SECTOR_NEWS_CTX.get(sector, []) + pos = sum(1 for d, s in lst if d_start <= d < d_end and s == 'positive') + neg = sum(1 for d, s in lst if d_start <= d < d_end and s == 'negative') + return {'pos': pos, 'neg': neg, 'total': pos + neg} + def prepare_weekly_context(start_date, end_date): """加载 stock_weekly,计算每周 MA10/MA20""" global _WEEKLY_CTX @@ -924,6 +1005,7 @@ def run_backtest(strategy_version, start_date, end_date, capital=913000, save=Tr 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(""" @@ -1288,6 +1370,8 @@ def run_backtest(strategy_version, start_date, end_date, capital=913000, save=Tr _sec_m = conv.get('sector_mult', 2.0) _flo_t = conv.get('flow_delta_min', 0) _flo_m = conv.get('flow_mult', 2.0) + _news_m = conv.get('news_mult', 0) # 新闻因子倍数(0=不启用) + _news_days = conv.get('news_days', 3) _cap = conv.get('cap') _stack = conv.get('stack', True) for t in trades: @@ -1296,6 +1380,15 @@ def run_backtest(strategy_version, start_date, end_date, capital=913000, save=Tr _sec_hit = _sa is not None and _sa > _sec_t _fd = _f.get('flow_delta') _flo_hit = _fd is not None and _fd > _flo_t + # 新闻因子(个股+行业) + _news_hit = False + if _news_m > 0: + _code = t.get('code') + _date = t.get('entry_date') + _sector = _STOCK_SECTOR.get(_code, '') + _sn = news_ctx(_code, _date, _news_days) + _ssn = sector_news_ctx(_sector, _date, _news_days) if _sector else {'pos': 0, 'neg': 0} + _news_hit = (_sn['pos'] + _ssn['pos']) > (_sn['neg'] + _ssn['neg']) and (_sn['pos'] + _ssn['pos']) >= 1 if _stack: b = 1.0 if t.get('dna'): @@ -1304,16 +1397,29 @@ def run_backtest(strategy_version, start_date, end_date, capital=913000, save=Tr b *= _sec_m if _flo_hit: b *= _flo_m + if _news_hit: + b *= _news_m if _cap: b = min(b, _cap) else: - b = _dna_m if (t.get('dna') or _sec_hit or _flo_hit) else 1.0 + b = _dna_m if (t.get('dna') or _sec_hit or _flo_hit or _news_hit) else 1.0 t['boost'] = b summary['conviction_model'] = conv.get('model', '') else: boost_k = cfg.get('exit', {}).get('dna_boost', 2.5) + _news_m = cfg.get('news_mult', 0) + _news_days = cfg.get('news_days', 3) for t in trades: - t['boost'] = boost_k if t.get('dna') else 1.0 + b = boost_k if t.get('dna') else 1.0 + if _news_m > 0: + _code = t.get('code') + _date = t.get('entry_date') + _sector = _STOCK_SECTOR.get(_code, '') + _sn = news_ctx(_code, _date, _news_days) + _ssn = sector_news_ctx(_sector, _date, _news_days) if _sector else {'pos': 0, 'neg': 0} + if (_sn['pos'] + _ssn['pos']) > (_sn['neg'] + _ssn['neg']) and (_sn['pos'] + _ssn['pos']) >= 1: + b *= _news_m + t['boost'] = b # 集中仓位(该策略最优激进仓位) slots = STRATEGY_SIZING.get(strategy_version, 10) summary['portfolio'] = portfolio_sim(trades, capital, slots)