A股双周调仓:一键日常量化脚本(AkShare)
你要的是能每天一键跑、两次/周调仓、风控中性的实用脚本。下面这套方案以沪深300成分为底层池(稳流动性,避免幸存者偏差),周二/周四收盘后出信号,次日开盘按目标权重成交,含成本、滑点与涨跌停成交约束,并输出订单与绩效图。
策略与风控设计
- 目标市场与频率: A股,周二/周四调仓;信号用当日收盘数据,次日开盘成交,避开“偷看未来”。
- 标的池: 沪深300成分,辅以滚动流动性过滤(近60日日均成交量门槛)。
- 选股逻辑(中风险,趋势+动量+波动约束):
- 趋势: 20日均线在60日均线上方,收盘在MA20上方。
- 动量: 126日动量为正;RSI处于温和区间(45–65)以降低追涨顶。
- 波动/过热: 收盘低于布林带上轨,避免过度扩张。
- 持仓构建: 从通过筛选的股票中选前 TopK(按动量排序),用波动率倒数配权,单票上限 12%,默认最多 15 只。
- 权重公式:对第 (i) 只,计算 20 日年化波动 (\sigma_i),原始权重 (w_i’ = 1/\sigma_i),归一化后得到 (w_i),并进行上限裁剪与再归一化。
- 交易与成本:
- 成交价: 次日开盘价。
- 费率: 佣金 0.0005(双边),卖出印花税 0.001(单边),滑点 0.0005(双边)。
- 涨跌停: 若次日开盘相对昨收涨幅 ≥9.5%(买入)或跌幅 ≤-9.5%(卖出),视为无法成交,当天该笔订单跳过。
- 绩效与图形: 组合净值曲线、最大回撤、持仓变动与当日调仓订单 CSV。
一键日常脚本(直接可运行)
先安装依赖:
pip install akshare pandas numpy matplotlib
# -*- coding: utf-8 -*-
# A股双周调仓 一键日常研究与回测脚本(AkShare)
# 运行环境:Python 3.9+;依赖:akshare, pandas, numpy, matplotlib
import akshare as ak
import pandas as pd
import numpy as np
import time
from datetime import datetime, timedelta
import matplotlib.pyplot as plt
# 修改为Windows系统默认中文字体
plt.rcParams["font.family"] = ["SimHei", "Microsoft YaHei", "SimSun"]
plt.rcParams["axes.unicode_minus"] = False # 解决负号显示问题
# -----------------------
# 参数区(按需修改)
# -----------------------
START_DATE = "2018-01-01"
END_DATE = None # None 表示到今日
CASH_INIT = 1_000_000
MAX_POS = 15 # 最多持仓数
MAX_W = 0.12 # 单票权重上限
FEE_COMM = 0.0005 # 佣金
FEE_STAMP = 0.001 # 印花税(仅卖出)
SLIPPAGE = 0.0005 # 滑点
LIQ_VOL_TH = 1_000_000 # 近60日平均成交量门槛(手/股),可按需要调
REBAL_WEEKDAYS = {1, 3} # 周二(1)、周四(3) 调仓;Python: Mon=0
# -----------------------
# 工具函数:指标
# -----------------------
def sma(s, n):
return s.rolling(n).mean()
def rsi(close, n=14):
delta = close.diff()
up = np.where(delta > 0, delta, 0.0)
dn = np.where(delta < 0, -delta, 0.0)
up_ema = pd.Series(up, index=close.index).ewm(alpha=1/n, adjust=False).mean()
dn_ema = pd.Series(dn, index=close.index).ewm(alpha=1/n, adjust=False).mean()
rs = up_ema / dn_ema.replace(0, np.nan)
return 100 - (100 / (1 + rs))
def bbands(close, n=20, k=2):
mid = close.rolling(n).mean()
std = close.rolling(n).std(ddof=0)
up = mid + k * std
dn = mid - k * std
return mid, up, dn
def true_range(df):
prev_close = df["close"].shift(1)
tr = pd.concat([
(df["high"] - df["low"]).abs(),
(df["high"] - prev_close).abs(),
(df["low"] - prev_close).abs()
], axis=1).max(axis=1)
return tr
def ann_vol(close, n=20):
ret = close.pct_change()
return ret.rolling(n).std() * np.sqrt(252)
# -----------------------
# 数据获取与基准日历
# -----------------------
def get_trade_calendar(start=START_DATE, end=END_DATE):
cal = ak.tool_trade_date_hist_sina()
cal["trade_date"] = pd.to_datetime(cal["trade_date"])
if end is None:
end = datetime.now().strftime("%Y-%m-%d")
cal = cal[(cal["trade_date"] >= pd.to_datetime(start)) &
(cal["trade_date"] <= pd.to_datetime(end))]["trade_date"].sort_values()
return cal.tolist()
def get_hs300_symbols():
df = ak.index_stock_cons(symbol="000300")
# 列可能是 '品种代码' 或 '成分券代码'; 做兼容
for col in ["品种代码", "成分券代码", "代码", "code"]:
if col in df.columns:
return sorted(df[col].astype(str).str.zfill(6).unique().tolist())
# 兜底
return sorted(df.iloc[:,0].astype(str).str.zfill(6).unique().tolist())
def get_hist(code, start=START_DATE, end=END_DATE, adjust="qfq"):
if end is None:
end = datetime.now().strftime("%Y%m%d")
df = ak.stock_zh_a_hist(symbol=code, period="daily",
start_date=start.replace("-",""),
end_date=end.replace("-",""),
adjust=adjust)
# 兼容列名
mapper = {"日期":"date","开盘":"open","收盘":"close","最高":"high","最低":"low","成交量":"volume","成交额":"amount"}
df = df.rename(columns=mapper)
df["date"] = pd.to_datetime(df["date"])
cols = [c for c in ["date","open","high","low","close","volume","amount"] if c in df.columns]
df = df[cols].set_index("date").sort_index()
df = df.dropna()
return df
# -----------------------
# 信号与筛选
# -----------------------
def compute_indicators(df):
out = df.copy()
out["MA20"] = sma(out["close"], 20)
out["MA60"] = sma(out["close"], 60)
out["RSI14"] = rsi(out["close"], 14)
out["MOM126"] = out["close"] / out["close"].shift(126) - 1
mid, up, dn = bbands(out["close"], 20, 2)
out["BB_MID"], out["BB_UP"], out["BB_DN"] = mid, up, dn
out["ANNVOL20"] = ann_vol(out["close"], 20)
out["TR"] = true_range(out)
out["ATR20"] = out["TR"].rolling(20).mean()
return out
def pass_screen(row):
c1 = row["MA20"] > row["MA60"]
c2 = row["close"] > row["MA20"]
c3 = row["MOM126"] > 0
c4 = 45 <= row["RSI14"] <= 65
c5 = row["close"] < row["BB_UP"]
return c1 and c2 and c3 and c4 and c5
# -----------------------
# 回测:两次/周调仓,次日开盘成交
# -----------------------
def backtest_portfolio(symbols, start=START_DATE, end=END_DATE, cash_init=CASH_INIT):
# 下载数据
data = {}
for i, sym in enumerate(symbols, 1):
try:
df = get_hist(sym, start, end)
data[sym] = compute_indicators(df)
except Exception:
pass
time.sleep(0.2) # 温和限速
# 统一日历
all_dates = sorted(set().union(*[df.index for df in data.values()]))
cal = pd.DatetimeIndex(all_dates)
# 选择调仓日(周二/周四且是交易日)
rebal_days = [d for d in cal if d.weekday() in REBAL_WEEKDAYS]
# 过滤:近60日均量
def liquid_ok(df, dt):
window = df.loc[:dt].tail(60)
if "volume" not in window:
return True
return window["volume"].mean() >= LIQ_VOL_TH
# 状态
cash = cash_init
positions = {} # sym -> shares
nav_series = []
dd_series = []
equity = cash
peak = equity
last_prices = {}
# 逐日仿真
for i, d in enumerate(cal[:-1]): # 至倒数第二天(因次日开盘成交)
todays_vals = {}
# 更新持仓市值
for sym, df in data.items():
if d in df.index:
last_prices[sym] = df.at[d, "close"]
if sym in positions and sym in last_prices:
todays_vals[sym] = positions[sym] * last_prices[sym]
equity = cash + sum(todays_vals.values())
peak = max(peak, equity)
drawdown = (equity / peak) - 1
nav_series.append((d, equity))
dd_series.append((d, drawdown))
# 调仓信号(用今日收盘)
if d in rebal_days:
# 生成候选
candidates = []
for sym, df in data.items():
if d not in df.index:
continue
if not liquid_ok(df, d):
continue
row = df.loc[d]
# 要求指标有效
if np.any(pd.isna(row[["MA20","MA60","RSI14","MOM126","BB_UP","ANNVOL20"]])):
continue
if pass_screen(row):
candidates.append((sym, row["MOM126"], row["ANNVOL20"]))
# 排序与截断
candidates.sort(key=lambda x: x[1], reverse=True)
picks = candidates[:MAX_POS]
# 计算目标权重(波动率倒数)
if picks:
vols = np.array([max(1e-6, x[2]) for x in picks])
inv = 1.0 / vols
w_raw = inv / inv.sum()
# 单票上限
w_capped = np.minimum(w_raw, MAX_W)
w = w_capped / w_capped.sum()
target = {sym: w[j] for j, (sym, _, _) in enumerate(picks)}
else:
target = {}
# 次日开盘执行
nd = cal[i+1]
# 构建目标头寸价值
target_value = {sym: equity * w for sym, w in target.items()}
# 先卖出未在目标内或超配部分
for sym in list(positions.keys()):
df = data.get(sym)
if df is None or nd not in df.index or d not in df.index:
continue
prev_close = df.at[d, "close"]
next_open = df.at[nd, "open"]
# 跌停无法卖出(近似)
if next_open <= prev_close * (1 - 0.095):
continue
price = next_open * (1 - SLIPPAGE)
cur_val = positions[sym] * price
tgt_val = target_value.get(sym, 0.0)
if cur_val > tgt_val + 1: # 超配或不在目标
sell_val = cur_val - tgt_val
shares = int(sell_val // price)
if shares > 0:
proceeds = shares * price * (1 - FEE_COMM - FEE_STAMP)
positions[sym] -= shares
if positions[sym] <= 0:
positions.pop(sym, None)
cash += proceeds
# 再买入不达标或新标的
for sym, tgt_val in target_value.items():
df = data.get(sym)
if df is None or nd not in df.index or d not in df.index:
continue
prev_close = df.at[d, "close"]
next_open = df.at[nd, "open"]
# 涨停无法买入(近似)
if next_open >= prev_close * (1 + 0.095):
continue
price = next_open * (1 + SLIPPAGE)
cur_shares = positions.get(sym, 0)
cur_val = cur_shares * price
buy_val = max(0.0, tgt_val - cur_val)
shares = int(buy_val // price)
if shares > 0 and cash > shares * price * (1 + FEE_COMM):
cost = shares * price * (1 + FEE_COMM)
cash -= cost
positions[sym] = cur_shares + shares
nav = pd.Series({d: v for d, v in nav_series}).sort_index()
dd = pd.Series({d: v for d, v in dd_series}).sort_index()
ret = nav.pct_change().fillna(0)
stats = {
"CAGR": (nav.iloc[-1] / nav.iloc[0]) ** (252/len(nav)) - 1,
"Vol": ret.std() * np.sqrt(252),
"Sharpe": (ret.mean() / (ret.std() + 1e-9)) * np.sqrt(252),
"MaxDD": dd.min()
}
return nav, dd, positions, stats
# -----------------------
# 今日调仓计划(实用日常)
# -----------------------
def today_rebalance_plan():
today = pd.Timestamp(datetime.now().date())
# 若今天不是交易日或不是周二/周四,直接提示
cal = get_trade_calendar((today - pd.Timedelta(days=10)).strftime("%Y-%m-%d"),
today.strftime("%Y-%m-%d"))
cal_idx = pd.DatetimeIndex(cal)
if today not in cal_idx or today.weekday() not in REBAL_WEEKDAYS:
print("今天不是计划调仓日(或非交易日)。")
return
syms = get_hs300_symbols()
plan_rows = []
for sym in syms:
try:
df = get_hist(sym, (today - pd.Timedelta(days=400)).strftime("%Y-%m-%d"),
today.strftime("%Y-%m-%d"))
df = compute_indicators(df)
if len(df) < 200 or today not in df.index:
continue
row = df.loc[today]
# 流动性
if "volume" in df:
if df.loc[:today].tail(60)["volume"].mean() < LIQ_VOL_TH:
continue
if np.any(pd.isna(row[["MA20","MA60","RSI14","MOM126","BB_UP","ANNVOL20"]])):
continue
if pass_screen(row):
plan_rows.append({
"code": sym,
"mom126": row["MOM126"],
"annvol20": row["ANNVOL20"],
"close": row["close"]
})
except Exception:
pass
time.sleep(0.05)
if not plan_rows:
print("今日无标的通过筛选。")
return
dfp = pd.DataFrame(plan_rows).sort_values("mom126", ascending=False).head(MAX_POS)
inv = 1.0 / np.maximum(1e-6, dfp["annvol20"].values)
w_raw = inv / inv.sum()
w_capped = np.minimum(w_raw, MAX_W)
w = w_capped / w_capped.sum()
dfp["target_weight"] = w
dfp.to_csv(f"rebalance_plan_{today.strftime('%Y%m%d')}.csv", index=False, encoding="utf-8-sig")
print("今日计划(次日开盘执行,权重已截顶):")
print(dfp[["code","target_weight","mom126","annvol20","close"]])
# -----------------------
# 主函数:回测 + 图形 + 今日计划
# -----------------------
if __name__ == "__main__":
print("获取沪深300成分...")
symbols = get_hs300_symbols()
print(f"成分股数量:{len(symbols)}")
print("开始回测(这可能需要几分钟)...")
nav, dd, positions, stats = backtest_portfolio(symbols, START_DATE, END_DATE, CASH_INIT)
print("回测统计:")
for k, v in stats.items():
print(f"{k}: {v:.4f}")
# 绘制净值与回撤
fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True,
gridspec_kw={"height_ratios":[3,1]})
nav_norm = nav / nav.iloc[0]
ax[0].plot(nav_norm.index, nav_norm.values, label="Portfolio")
ax[0].set_title("组合净值(归一)")
ax[0].legend()
ax[0].grid(True, alpha=0.3)
ax[1].fill_between(dd.index, dd.values, 0, color="red", alpha=0.3)
ax[1].set_title("回撤")
ax[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("backtest_nav_drawdown.png", dpi=150)
plt.show()
# 当日调仓计划(在 CST 下午 15:10 后运行更合适)
today_rebalance_plan()
如何每天一键跑
- 时间点: 沧州本地时间建议在交易日 15:10 之后运行,保证当日收盘数据可用。
- 命令行:
- 运行: python run_quant.py
- 输出: backtest_nav_drawdown.png、rebalance_plan_YYYYMMDD.csv,并在控制台打印统计与当日计划。
- 定时任务:
- Linux crontab: 10 15 * * 1-5 /usr/bin/python3 /path/run_quant.py >> /path/log.txt 2>&1
- Windows 任务计划: 设为工作日 15:10 触发。
可调参数与扩展
- 可调参数:
- MAX_POS/MAX_W: 控制集中度与风险。
- LIQ_VOL_TH: 提高门槛可进一步降低流动性风险。
- 费率/滑点: 按你的券商费率与成交体验微调。
- 风控增强:
- 止损/跟踪止盈: 以 ATR 为单位,如价格跌破 MA60 或 3×ATR 跌幅平仓。
- 涨跌停细化: 科创/创业板 20% 规则可按股票板块调整阈值。
- 基准比较: 叠加沪深300指数净值,计算超额与信息比率。
- 研究维度:
- 横截面多因子: 以动量、波动、估值(PE/PB,需额外数据)做打分,周二/周四同频调仓。
- 再平衡鲁棒性: 改为“阈值再平衡”(偏离>25%才调仓)降低换手。
如果你想把标的池改为中证500或加入行业中性约束,或者把调仓日改成“每周最近的周二与周四,如遇节假日顺延”,我可以把上述脚本再细化成模块化的研究框架,并加上性能剖析与结果缓存来加速日常跑批。
以下是程序运行结果的详细解释:
- 数据获取与回测概况
沪深300成分股数量:282:成功获取了当前沪深300指数的282只成分股
回测耗时提示:策略回测需要一定计算时间,符合预期 - 核心回测指标解读
| 指标 | 数值 | 含义解释 | 策略评估 |
|——–|———|———————————–|—————————|
| CAGR | -0.0900 | 复合年化增长率:-9.00% | 策略整体年化亏损9% |
| Vol | 0.2348 | 波动率:23.48% | 收益波动较大 |
| Sharpe | -0.2843 | 夏普比率:-0.28 | 风险调整后收益为负,表现弱于无风险资产 |
| MaxDD | -0.3505 | 最大回撤:-35.05% | 策略历史最大亏损幅度为35% | - 今日投资计划(次日开盘执行)
| 代码 | 目标权重 | 126天动量(mom126) | 20天年化波动率(annvol20) | 收盘价 |
|——–|———-|——————-|————————–|——–|
| 300394 | 0.2 | 0.6719 | 0.7191 | 107.87 |
| 603799 | 0.2 | 0.4765 | 0.4454 | 44.25 |
| 002463 | 0.2 | 0.4701 | 0.5822 | 55.35 |
| 002074 | 0.2 | 0.3858 | 0.2900 | 30.46 |
| 000617 | 0.2 | 0.3205 | 0.4965 | 8.90 |
计划参数说明:
target_weight=0.2:采用等权重分配策略,每只股票配置20%仓位
mom126:126天动量指标(越高表示近期趋势越强)
annvol20:20天年化波动率(衡量短期风险,数值越低风险相对越小)
- 策略表现评估
风险收益特征:当前策略呈现负收益、高波动特征,夏普比率为负表明策略未能有效创造超额收益
最大回撤风险:35.05%的最大回撤需要警惕,可能超出多数投资者的风险承受能力
持仓策略:选择了5只动量特征较强的股票进行等权重配置,兼顾了动量因子和波动率控制