xiaowei-system/scripts/stock_industry_scan.py

236 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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&param={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()