236 lines
8.6 KiB
Python
236 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
小唯股票行业全景扫描 — 用学术因子找动量正行业
|
||
==========================================
|
||
扫描多行业代表股,计算动量/年化/Sharpe,找出当前值得关注的行业
|
||
用于扩充关注池(stock_portfolio.WATCHED_STOCKS)
|
||
|
||
行业覆盖: 白酒/银行/保险/证券/新能源/医药/消费/科技/地产/军工/煤炭/家电
|
||
"""
|
||
import json, sys, urllib.request
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
# 行业代表股(每行业 2-3 只)
|
||
SCAN_STOCKS = [
|
||
# 现有关注
|
||
("000858", "五粮液", "白酒"),
|
||
("600519", "贵州茅台", "白酒"),
|
||
("000568", "泸州老窖", "白酒"),
|
||
("002304", "洋河股份", "白酒"),
|
||
("600036", "招商银行", "银行"),
|
||
("601318", "中国平安", "保险"),
|
||
("000001", "平安银行", "银行"),
|
||
# 证券
|
||
("600030", "中信证券", "证券"),
|
||
("300059", "东方财富", "证券"),
|
||
# 新能源
|
||
("300750", "宁德时代", "新能源"),
|
||
("002594", "比亚迪", "新能源"),
|
||
# 医药
|
||
("600276", "恒瑞医药", "医药"),
|
||
("300760", "迈瑞医疗", "医药"),
|
||
# 消费
|
||
("600887", "伊利股份", "消费"),
|
||
("603288", "海天味业", "消费"),
|
||
# 科技
|
||
("002415", "海康威视", "科技"),
|
||
("000063", "中兴通讯", "科技"),
|
||
# 地产
|
||
("000002", "万科A", "地产"),
|
||
("600048", "保利发展", "地产"),
|
||
# 军工
|
||
("600893", "航发动力", "军工"),
|
||
("002179", "中航光电", "军工"),
|
||
# 煤炭
|
||
("601088", "中国神华", "煤炭"),
|
||
("600188", "兖矿能源", "煤炭"),
|
||
# 家电
|
||
("000333", "美的集团", "家电"),
|
||
("600690", "海尔智家", "家电"),
|
||
# 电力
|
||
("600900", "长江电力", "电力"),
|
||
("600886", "国投电力", "电力"),
|
||
# 通信/运营商
|
||
("600941", "中国移动", "通信"),
|
||
("601728", "中国电信", "通信"),
|
||
# 汽车
|
||
("601633", "长城汽车", "汽车"),
|
||
("600104", "上汽集团", "汽车"),
|
||
# 半导体
|
||
("688981", "中芯国际", "半导体"),
|
||
("603501", "韦尔股份", "半导体"),
|
||
# 军工(动量+5.2% 正)
|
||
("600893", "航发动力", "军工"),
|
||
("002179", "中航光电", "军工"),
|
||
("600760", "中航沈飞", "军工"),
|
||
# 电力
|
||
("600900", "长江电力", "电力"),
|
||
("600886", "国投电力", "电力"),
|
||
# 通信
|
||
("600941", "中国移动", "通信"),
|
||
("601728", "中国电信", "通信"),
|
||
("000063", "中兴通讯", "通信"),
|
||
# 家电
|
||
("000333", "美的集团", "家电"),
|
||
("600690", "海尔智家", "家电"),
|
||
("000651", "格力电器", "家电"),
|
||
# 医药(弱势观察)
|
||
("600276", "恒瑞医药", "医药"),
|
||
("300760", "迈瑞医疗", "医药"),
|
||
# 消费
|
||
("600887", "伊利股份", "消费"),
|
||
("603288", "海天味业", "消费"),
|
||
# 有色
|
||
("601899", "紫金矿业", "有色"),
|
||
("600547", "山东黄金", "有色"),
|
||
# 石油
|
||
("601857", "中国石油", "石油"),
|
||
("600028", "中国石化", "石油"),
|
||
# 航运
|
||
("601919", "中远海控", "航运"),
|
||
# 基建
|
||
("601668", "中国建筑", "基建"),
|
||
# 农业
|
||
("002714", "牧原股份", "农业"),
|
||
# 汽车(弱势)
|
||
("601633", "长城汽车", "汽车"),
|
||
# 地产(弱势)
|
||
("000002", "万科A", "地产"),
|
||
]
|
||
|
||
OUTPUT = Path.home() / ".hermes" / "stock_backtest"
|
||
OUTPUT.mkdir(exist_ok=True)
|
||
|
||
|
||
def get_long_data(code, count=300):
|
||
"""腾讯行情日线(前复权)"""
|
||
mc = f"sh{code}" if code.startswith(("6", "9")) else f"sz{code}"
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
start = (datetime.now() - timedelta(days=count * 2)).strftime("%Y-%m-%d")
|
||
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||
f"?_var=kline_dayqfq¶m={mc},day,{start},{today},{count},qfq")
|
||
try:
|
||
text = urllib.request.urlopen(url, timeout=15).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
|
||
try:
|
||
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])})
|
||
except (ValueError, IndexError):
|
||
continue
|
||
df = pd.DataFrame(rows)
|
||
if df.empty:
|
||
return None
|
||
df["date"] = pd.to_datetime(df["date"])
|
||
df.set_index("date", inplace=True)
|
||
df.sort_index(inplace=True)
|
||
return df
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def compute_momentum(df):
|
||
"""计算动量指标"""
|
||
if df is None or len(df) < 60:
|
||
return None
|
||
close = df["close"]
|
||
ret = close.pct_change()
|
||
n = len(df)
|
||
|
||
# 12m-1m 动量(Carhart)
|
||
mom = close.iloc[-21] / close.iloc[-252] - 1 if n > 252 else close.iloc[-5] / close.iloc[0] - 1
|
||
# 年化收益
|
||
total_ret = close.iloc[-1] / close.iloc[0] - 1
|
||
ann_ret = (1 + total_ret) ** (252 / n) - 1 if n > 0 else 0
|
||
# 波动率
|
||
vol60 = ret.iloc[-60:].std() * np.sqrt(252)
|
||
# Sharpe
|
||
sharpe = ann_ret / vol60 if vol60 > 0 else 0
|
||
# 52周高
|
||
lookback = min(252, n)
|
||
high52 = close.iloc[-1] / close.iloc[-lookback:].max()
|
||
# MA20 状态
|
||
ma20 = close.rolling(20).mean()
|
||
above_ma20 = close.iloc[-1] > ma20.iloc[-1]
|
||
# 趋势状态(MA20 vs MA60)
|
||
ma60 = close.rolling(60).mean()
|
||
trend_up = ma20.iloc[-1] > ma60.iloc[-1] if n > 60 else None
|
||
|
||
return {"mom": mom, "ann_ret": ann_ret, "sharpe": sharpe,
|
||
"high52": high52, "above_ma20": above_ma20, "trend_up": trend_up,
|
||
"close": close.iloc[-1], "ma20": ma20.iloc[-1]}
|
||
|
||
|
||
def main():
|
||
print("=" * 70)
|
||
print("小唯股票行业全景扫描 — 学术因子动量筛选")
|
||
print("=" * 70)
|
||
|
||
results = []
|
||
for code, name, industry in SCAN_STOCKS:
|
||
df = get_long_data(code, count=300)
|
||
f = compute_momentum(df)
|
||
if f is None:
|
||
print(f" ⚠️ {name}({code}) 数据不足")
|
||
continue
|
||
results.append({"code": code, "name": name, "industry": industry, **f})
|
||
|
||
rdf = pd.DataFrame(results)
|
||
rdf.set_index("name", inplace=True)
|
||
|
||
# 行业聚合
|
||
print("\n📈 行业动量排名(按平均动量)")
|
||
print("-" * 70)
|
||
ind_agg = rdf.groupby("industry").agg(
|
||
avg_mom=("mom", "mean"),
|
||
avg_ann=("ann_ret", "mean"),
|
||
avg_sharpe=("sharpe", "mean"),
|
||
avg_high52=("high52", "mean"),
|
||
count=("mom", "count")
|
||
).sort_values("avg_mom", ascending=False)
|
||
|
||
print(f"{'行业':<8} {'股票数':>4} {'平均动量':>9} {'年化':>8} {'Sharpe':>7} {'52周高':>7} {'动量状态'}")
|
||
print("-" * 70)
|
||
for ind, row in ind_agg.iterrows():
|
||
status = "🟢正" if row["avg_mom"] > 0 else "🔴负"
|
||
print(f"{ind:<8} {int(row['count']):>4} {row['avg_mom']:>+8.1%} {row['avg_ann']:>+7.1%} {row['avg_sharpe']:>+7.2f} {row['avg_high52']:>7.3f} {status}")
|
||
|
||
# 个股明细(动量正 + Sharpe正 的)
|
||
print("\n🏆 动量正 + Sharpe正 的个股(候选扩充关注池)")
|
||
print("-" * 70)
|
||
candidates = rdf[(rdf["mom"] > 0) & (rdf["sharpe"] > 0)]
|
||
if not candidates.empty:
|
||
for name, row in candidates.sort_values("sharpe", ascending=False).iterrows():
|
||
print(f" ✅ {name}({row['code']}) [{row['industry']}] 动量{row['mom']:+.1%} Sharpe{row['sharpe']:+.2f} 年化{row['ann_ret']:+.1%}")
|
||
else:
|
||
print(" (无)")
|
||
|
||
print("\n📋 现有关注池状态:")
|
||
for name, row in rdf[rdf.index.isin(["五粮液","贵州茅台","泸州老窖","洋河股份","招商银行","中国平安","平安银行"])].iterrows():
|
||
trend = "多头" if row["trend_up"] else "空头"
|
||
print(f" {'🟢' if row['mom']>0 else '🔴'} {name}({row['code']}) [{row['industry']}] 动量{row['mom']:+.1%} MA20{'上' if row['above_ma20'] else '下'} 趋势{trend}")
|
||
|
||
# 保存
|
||
out = OUTPUT / "industry_scan.json"
|
||
with open(out, "w", encoding="utf-8") as f:
|
||
json.dump({"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||
"industries": ind_agg.to_dict(),
|
||
"candidates": candidates.reset_index().to_dict("records"),
|
||
"stocks": results}, f, ensure_ascii=False, indent=2, default=str)
|
||
print(f"\n📁 已保存: {out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|