feat: 新闻因子接入回测引擎——prepare_news_context+conviction新闻倍数(叠乘/平铺双模式), v_next3 5y+9.8pp至64.6%

This commit is contained in:
hmo
2026-07-31 11:32:26 +08:00
parent 4880f1c1bf
commit a46c134b0d
+111 -5
View File
@@ -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)