248 lines
10 KiB
Python
248 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
股票策略增强收益验证 — 纯 MA20 vs MA20+行业动量过滤
|
||
=================================================
|
||
目的:量化证明 2026-08-01 加的"行业动量过滤"是否真的提高策略表现。
|
||
|
||
方法(同股同区间对比):
|
||
A. 纯 MA20:金叉买死叉卖(原策略)
|
||
B. MA20+行业过滤:金叉时若行业动量>0 才买,否则跳过(增强策略)
|
||
* 行业动量:行业代表股过去 252 日动量(12m-1m),动量≤0 时跳过买入
|
||
|
||
注意:行业过滤的效果在"下跌行业"里最明显——白酒近2年下跌,过滤后
|
||
应减少亏损交易、提高胜率。
|
||
"""
|
||
import sys, json, urllib.request
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
OUTPUT = Path.home() / ".hermes" / "stock_backtest"
|
||
|
||
# 行业代表股映射(行业动量代理)
|
||
INDUSTRY_PROXY = {
|
||
"白酒": "600519", # 贵州茅台
|
||
"银行": "600036", # 招商银行
|
||
"保险": "601318", # 中国平安
|
||
"新能源": "300750", # 宁德时代
|
||
"科技": "002415", # 海康威视
|
||
"煤炭": "601088", # 中国神华
|
||
"半导体": "688981", # 中芯国际
|
||
"证券": "600030", # 中信证券
|
||
"家电": "000333", # 美的集团
|
||
"医药": "600276", # 恒瑞医药
|
||
"通信": "600941", # 中国移动
|
||
"汽车": "601633", # 长城汽车
|
||
"地产": "000002", # 万科A
|
||
}
|
||
|
||
# 测试股票(跨行业:白酒下跌 + 新能源上涨 + 银行中性)
|
||
TEST_STOCKS = [
|
||
("000858", "五粮液", "白酒"),
|
||
("600519", "贵州茅台", "白酒"),
|
||
("000568", "泸州老窖", "白酒"),
|
||
("300750", "宁德时代", "新能源"),
|
||
("002415", "海康威视", "科技"),
|
||
("600036", "招商银行", "银行"),
|
||
]
|
||
|
||
|
||
def get_data(code, count=500):
|
||
"""腾讯行情日线(前复权)"""
|
||
mc = f"sh{code}" if code.startswith("6") else f"sz{code}"
|
||
end = datetime.now().strftime("%Y-%m-%d")
|
||
start = (datetime.now() - timedelta(days=int(count * 1.5))).strftime("%Y-%m-%d")
|
||
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||
f"?_var=kline_dayqfq¶m={mc},day,{start},{end},{count},qfq")
|
||
try:
|
||
text = urllib.request.urlopen(url, timeout=12).read().decode("utf-8")
|
||
data = json.loads(text.replace("kline_dayqfq=", "", 1))
|
||
qfq = (data.get("data", {}).get(mc, {}).get("qfqday") or
|
||
data.get("data", {}).get(mc, {}).get("day") or [])
|
||
rows = []
|
||
for item in qfq:
|
||
if len(item) < 6:
|
||
continue
|
||
rows.append({"date": item[0], "open": float(item[1]),
|
||
"close": float(item[2]), "high": float(item[3]),
|
||
"low": float(item[4]), "volume": float(item[5])})
|
||
df = pd.DataFrame(rows)
|
||
df["date"] = pd.to_datetime(df["date"])
|
||
return df.sort_values("date").reset_index(drop=True)
|
||
except Exception:
|
||
return pd.DataFrame()
|
||
|
||
|
||
def compute_industry_momentum(proxy_code):
|
||
"""计算行业动量:代理股过去 252 日动量(12m-1m),返回时间序列
|
||
返回 DataFrame 带 momentum 列(每日滚动 12m-1m 动量)
|
||
"""
|
||
df = get_data(proxy_code, count=400)
|
||
if df.empty or len(df) < 60:
|
||
return None
|
||
df["momentum"] = df["close"].shift(21) / df["close"].shift(252) - 1
|
||
return df[["date", "momentum"]]
|
||
|
||
|
||
def run_backtest(df, industry_filter=None):
|
||
"""
|
||
通用 MA20 回测
|
||
industry_filter: None=纯MA20; DataFrame(date, momentum)=动量过滤
|
||
返回: (strat_return, hold_return, alpha, max_dd, n_trades, win_rate, trades)
|
||
"""
|
||
if df.empty or len(df) < 30:
|
||
return None
|
||
|
||
d = df.copy()
|
||
d["ma20"] = d["close"].rolling(window=20).mean()
|
||
d["prev_close"] = d["close"].shift(1)
|
||
d["prev_ma20"] = d["ma20"].shift(1)
|
||
d["golden_cross"] = (d["prev_close"] < d["prev_ma20"]) & (d["close"] > d["ma20"])
|
||
d["death_cross"] = (d["prev_close"] > d["prev_ma20"]) & (d["close"] < d["ma20"])
|
||
|
||
# 合并行业动量(如果启用过滤)
|
||
if industry_filter is not None:
|
||
d = d.merge(industry_filter[["date", "momentum"]], on="date", how="left")
|
||
|
||
cash = 100000
|
||
position = 0
|
||
in_position = False
|
||
buy_price = 0
|
||
trades = []
|
||
equity = []
|
||
|
||
for i, row in d.iterrows():
|
||
if pd.isna(row["ma20"]):
|
||
continue
|
||
price = row["close"]
|
||
date_str = row["date"].strftime("%Y-%m-%d")
|
||
|
||
# 金叉买入(可被行业动量过滤)
|
||
if row["golden_cross"] and not in_position:
|
||
if industry_filter is not None:
|
||
mom = row.get("momentum", np.nan)
|
||
# 动量过滤:动量≤0 或缺失时跳过
|
||
if pd.isna(mom) or mom <= 0:
|
||
trades.append({"type": "SKIP", "date": date_str, "price": price, "reason": f"行业动量{mom:+.1%}过滤"})
|
||
equity.append({"date": date_str, "value": cash, "price": price})
|
||
continue
|
||
shares = int(cash / price)
|
||
if shares > 0:
|
||
cash -= shares * price
|
||
position = shares
|
||
buy_price = price
|
||
in_position = True
|
||
trades.append({"type": "BUY", "date": date_str, "price": price, "shares": shares, "reason": "金叉"})
|
||
|
||
# 死叉卖出
|
||
elif row["death_cross"] and in_position:
|
||
proceeds = position * price
|
||
cash += proceeds
|
||
profit_pct = (price - buy_price) / buy_price * 100
|
||
trades.append({"type": "SELL", "date": date_str, "price": price, "shares": position, "profit_pct": profit_pct, "reason": "死叉"})
|
||
position = 0
|
||
in_position = False
|
||
buy_price = 0
|
||
|
||
equity.append({"date": date_str, "value": cash + position * price, "price": price})
|
||
|
||
final_price = d.iloc[-1]["close"]
|
||
final_value = cash + position * final_price
|
||
|
||
# 买入持有
|
||
buy_price_hold = d.iloc[19]["close"] if len(d) > 19 else d.iloc[0]["close"]
|
||
hold_return = (final_price - buy_price_hold) / buy_price_hold * 100 if buy_price_hold else 0
|
||
strat_return = (final_value - 100000) / 100000 * 100
|
||
alpha = strat_return - hold_return
|
||
|
||
# 最大回撤
|
||
eq = [e["value"] for e in equity] or [100000]
|
||
peak = eq[0]
|
||
max_dd = 0
|
||
for v in eq:
|
||
peak = max(peak, v)
|
||
max_dd = max(max_dd, (peak - v) / peak * 100)
|
||
|
||
# 胜率
|
||
sells = [t for t in trades if t["type"] == "SELL"]
|
||
wins = [t for t in sells if t.get("profit_pct", 0) > 0]
|
||
win_rate = len(wins) / len(sells) * 100 if sells else 0
|
||
|
||
return {
|
||
"strat_return": strat_return, "hold_return": hold_return,
|
||
"alpha": alpha, "max_dd": max_dd,
|
||
"n_trades": len(sells), "n_skips": sum(1 for t in trades if t["type"] == "SKIP"),
|
||
"win_rate": win_rate, "final_value": final_value,
|
||
"in_position": in_position, "trades": trades[-15:],
|
||
}
|
||
|
||
|
||
def main():
|
||
print("=" * 72)
|
||
print("策略增强收益验证 — 纯 MA20 vs MA20+行业动量过滤")
|
||
print("=" * 72)
|
||
|
||
results = []
|
||
for code, name, industry in TEST_STOCKS:
|
||
df = get_data(code, 500)
|
||
if df.empty:
|
||
print(f" ⚠️ {name} 数据获取失败")
|
||
continue
|
||
|
||
# A. 纯 MA20
|
||
r_plain = run_backtest(df, industry_filter=None)
|
||
|
||
# B. MA20 + 行业动量过滤
|
||
proxy = INDUSTRY_PROXY.get(industry)
|
||
ind_mom = compute_industry_momentum(proxy) if proxy else None
|
||
r_filtered = run_backtest(df, industry_filter=ind_mom) if ind_mom is not None else None
|
||
|
||
if not r_plain:
|
||
continue
|
||
|
||
print(f"\n📊 {name}({code}) [{industry}] {df['date'].min().date()}~{df['date'].max().date()}")
|
||
print(f" {'指标':<12} {'A.纯MA20':>12} {'B.动量过滤':>12} {'差异':>10}")
|
||
print(f" {'-'*46}")
|
||
print(f" {'策略收益':<12} {r_plain['strat_return']:>+11.2f}% {r_filtered['strat_return']:>+11.2f}% {r_filtered['strat_return']-r_plain['strat_return']:>+9.2f}%")
|
||
print(f" {'买入持有':<12} {r_plain['hold_return']:>+11.2f}% {'':>12}")
|
||
print(f" {'超额收益α':<12} {r_plain['alpha']:>+11.2f}% {r_filtered['alpha']:>+11.2f}% {r_filtered['alpha']-r_plain['alpha']:>+9.2f}%")
|
||
print(f" {'最大回撤':<12} {r_plain['max_dd']:>11.2f}% {r_filtered['max_dd']:>11.2f}% {r_filtered['max_dd']-r_plain['max_dd']:>+9.2f}%")
|
||
print(f" {'交易次数':<12} {r_plain['n_trades']:>12} {r_filtered['n_trades']:>12} {r_filtered['n_trades']-r_plain['n_trades']:>+10}")
|
||
print(f" {'胜率':<12} {r_plain['win_rate']:>11.1f}% {r_filtered['win_rate']:>11.1f}% {r_filtered['win_rate']-r_plain['win_rate']:>+9.1f}%")
|
||
if r_filtered and r_filtered["n_skips"] > 0:
|
||
print(f" (过滤跳过 {r_filtered['n_skips']} 次金叉)")
|
||
|
||
results.append({
|
||
"code": code, "name": name, "industry": industry,
|
||
"plain": {k: v for k, v in r_plain.items() if k != "trades"},
|
||
"filtered": {k: v for k, v in r_filtered.items() if k != "trades"} if r_filtered else None,
|
||
})
|
||
|
||
# 汇总
|
||
print("\n" + "=" * 72)
|
||
print("📈 汇总(过滤增强 vs 纯MA20)")
|
||
print("=" * 72)
|
||
valid = [r for r in results if r["filtered"]]
|
||
if valid:
|
||
avg_alpha_gain = np.mean([r["filtered"]["alpha"] - r["plain"]["alpha"] for r in valid])
|
||
avg_dd_gain = np.mean([r["filtered"]["max_dd"] - r["plain"]["max_dd"] for r in valid])
|
||
avg_win_gain = np.mean([r["filtered"]["win_rate"] - r["plain"]["win_rate"] for r in valid])
|
||
avg_trade_reduce = np.mean([r["plain"]["n_trades"] - r["filtered"]["n_trades"] for r in valid])
|
||
print(f" 平均 α 增益: {avg_alpha_gain:+.2f}%")
|
||
print(f" 平均回撤变化: {avg_dd_gain:+.2f}% (负=回撤减小更好)")
|
||
print(f" 平均胜率变化: {avg_win_gain:+.2f}%")
|
||
print(f" 平均交易减少: {avg_trade_reduce:.1f} 次/股 (过滤噪音)")
|
||
print(f"\n 结论: {'✅ 行业动量过滤显著增强策略(α↑/回撤↓/胜率↑)' if avg_alpha_gain > 0 and avg_dd_gain <= 0 else '⚠️ 过滤效果不显著,需调整动量阈值'}")
|
||
|
||
out = OUTPUT / "enhance_validation.json"
|
||
with open(out, "w", encoding="utf-8") as f:
|
||
json.dump({"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||
"method": "同股同区间对比: 纯MA20 vs MA20+行业动量过滤(12m-1m>0)",
|
||
"results": results}, f, ensure_ascii=False, indent=2)
|
||
print(f"\n📁 已保存: {out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|