简介:一个围绕股市分析场景整理的Jupyter Notebook项目,面向希望用Python开展金融数据探索、量化策略模拟与可视化实践的学习者,尤其适合有基础Pandas经验、想接触端到端数据分析流程的入门者。资源包共34个文件、大小约1.75MB,主要包含14个Python脚本、3个Notebook以及若干配置与文档;Python模块按功能承担数据获取、特征工程、模型封装和可视化等任务,三个Notebook分别对标普500指数进行探索性数据分析、解读超级投资者持仓特征,并完成买入持有策略的收益与回撤模拟,Makefile和RST文档则帮助快速重建环境与查看使用说明。项目沿用标准数据科学项目目录结构,data、models、src、docs分区清晰,src下细化出features、data、visualization等子模块,代码组织可直接复用,也适合在此基础上扩展新策略或新指标;该资源已有470人学习下载,对希望通过完整案例打通‘数据清洗—特征观察—策略模拟’闭环的读者,是一份结构完整、条理清晰、可读性高的参考项目。
1. stock_analysis:一套可落地的A股量化分析框架,不是算命工具
晚上复盘的时候,很多人面对一堆行情数据是蒙的:行情软件里均线、MACD都画好了,可真要自己验证某个策略赚不赚钱,打开Excel从头开始整理数据又不知道从哪下手。这个 stock_analysis 项目,本质上是把「数据获取 → 指标计算 → 信号生成 → 可视化 → 回测验证」串成一条可复现的流水线,让一个交易想法在历史行情里先跑一遍,再决定要不要实盘。它解决的是“某一套技术指标在过去的市场里到底赚不赚钱”这个问题,适合想搭自己的研究框架、又不想一上来就上重型量化平台的个人投资者。多数人写股市分析代码,第一步就栽在数据不干净上,这篇就从这里开始。
2. 技术路线选型:数据源、指标引擎与本地存储
2.1 免费数据源怎么选:akshare、tushare 与行情接口的取舍
做 A 股分析,第一步是拿到靠谱的行情数据,这一步决定了后面所有计算的正确性。目前在从业者里被反复验证的免费方案大致有三条:akshare、tushare、以及直接抓东财或腾讯的行情接口。
akshare 的优点是零门槛,pip 安装后直接调函数就行,能覆盖股票、指数、基金,历史日线拉得比较全。tushare 需要注册账号拿 token,不同接口对应不同积分,如果不花钱,很多历史数据的调取频率会被卡得很死,适合做小范围验证或测试。直接抓行情接口看似省事,但接口的字段含义经常变,今天能用的字段明天可能就返回空值,维护成本不低。
我一般把 akshare 作为首选,原因很直接:上手快、文档里有现成例子、日常的日线级分析完全够用。数据源选型不是越全越好,而是要考虑更新频率和数据稳定性。做日线策略,每天收盘后拉一次数据就够了;做分钟级策略,对数据质量和接口频率的要求会高很多,免费方案会非常吃力,那是另一个量级的问题。
2.2 为什么指标计算用 pandas 向量化而不是 for 循环
技术指标大多是滑窗计算,均线、布林带、MACD 都属于这一类。最自然的写法是用 for 循环逐行算,这在几十行数据时感觉不到问题,但数据量一上来,循环就成了瓶颈。
import pandas as pd # 坏习惯:for 循环算 20 日均线,数据一多就慢 close = df["close"] ma = [] for i in range(len(close)): ma.append(close[max(0, i - 19):i + 1].mean()) # 好习惯:rolling 一次到位,底层是 C 实现 ma = close.rolling(20).mean()这段代码对比了两条路线。循环版每行都要重新切片、求均值,时间复杂度和数据量呈线性关系,看着能跑,但一旦扩展到全市场几千只股票的筛选,或者把数据粒度从日线切到分钟线,循环就会拖到不可用。rolling 版本把滑窗操作交给 pandas 底层去做,速度差一个数量级还不止,代码也更短。
向量化的另一层意义是避免索引错位。手写循环时稍不注意就会在窗口边界处得到 NaN,而 rolling 和 ewm(指数加权)对边界值有明确约定,结果可复现。这也是后面所有指标计算采用 pandas 原生方法的原因。
2.3 本地落盘:用 parquet 而不是 CSV
数据拉下来之后要存到本地,很多人顺手就存成了 CSV。CSV 体积大、读写慢,还会丢失数据类型,日期列存成字符串之后每次读回来都要重新解析,这在小项目里无所谓,但数据量上来了就是隐患。我建议直接用 parquet 格式。
parquet 是列式存储,读取时只需要加载用到的列,体积通常只有 CSV 的三分之一左右,而且原生保留 pandas 的数据类型。一个小函数可以同时处理首次写入和增量追加:
from pathlib import Path def save_parquet(df: pd.DataFrame, path: str) -> None: """把日线数据写入 parquet,已存在时按日期去重合并。""" if Path(path).exists(): old = pd.read_parquet(path) df = pd.concat([old, df], ignore_index=True) df = df.drop_duplicates(subset=["date"]).sort_values("date") df.to_parquet(path, index=False)逻辑说明:先检查目标文件是否存在,存在就读取旧数据、拼接新数据,然后按 date 列去重并排序,最后写回。这里最关键的是 drop_duplicates,因为盘中重复拉取或者接口重试都可能导致同一交易日出现多行数据,不去重的话后面计算指标会出幻觉。参数 path 是文件的保存位置,建议按股票代码分文件存放,例如 data/600519.parquet。
CSV 不是不能用,只是每次读写都要处理格式问题,而 parquet 让整个流水线少掉一层麻烦。数据落盘是整个分析框架的地基,这一层做稳定了,后面的指标计算和回测才站得住。
3. 核心功能拆解:指标计算、买卖信号与可视化落地
3.1 用 pandas 实现 MACD、布林带:ewm 与 rolling 的参数选择
指标计算是 stock_analysis 的核心环节,但这里不需要自己发明算法。MACD 和布林带是技术分析里最常见的两个指标,用 pandas 十几行就能实现。
def add_indicators(df: pd.DataFrame, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame: """在日线 DataFrame 上追加 MACD 与布林带指标列。""" # MACD:DIF 为快慢指数均线之差,DEA 为 DIF 的指数均线 ema_fast = df["close"].ewm(span=fast, adjust=False).mean() ema_slow = df["close"].ewm(span=slow, adjust=False).mean() df["macd_dif"] = ema_fast - ema_slow df["macd_dea"] = df["macd_dif"].ewm(span=signal, adjust=False).mean() df["macd_hist"] = (df["macd_dif"] - df["macd_dea"]) * 2 # 布林带:20 日均线上下 2 倍标准差 mid = df["close"].rolling(20).mean() std = df["close"].rolling(20).std() df["boll_mid"] = mid df["boll_upper"] = mid + 2 * std df["boll_lower"] = mid - 2 * std return df逻辑说明:ewm 计算指数加权均值,比普通均线对近期价格更敏感。参数 adjust=False 表示从第一条数据开始按递推方式计算权重,这与国内行情软件里常用的 MACD 口径更一致;如果默认的 adjust=True,前几期的数值会有差异,导致和软件里的指标对不上。rolling(20).mean() 和 rolling(20).std() 是 20 日窗口的均值与标准差,布林带默认取 2 倍标准差,这个倍数决定了通道宽度,不是必须固定的,后面调参时可以一起试。
参数说明:fast、slow 是两条指数均线的周期,默认 12 和 26 是 MACD 的标准参数,signal 是 DIF 再平滑的周期,默认 9。这些默认值来自早期技术分析的习惯,沿用至今是因为大多数人都在看同样的口径,不代表它们最优。布林带的窗口这里写死为 20,如果你想单独调,可以再抽成参数,但初版不建议搞太多可变项,否则后面的调参会失控。
3.2 买卖信号生成:金叉、死叉与布林带突破的组合逻辑
指标算完之后,下一步是把指标翻译成交易信号。最简单的信号规则是金叉死叉:DIF 上穿 DEA 买入,下穿卖入。单独用金叉死叉在震荡行情里会被反复打脸,所以要加一个过滤条件。
def generate_signals(df: pd.DataFrame) -> pd.DataFrame: """生成买入与卖出信号:金叉/死叉 + 布林带下轨过滤。""" df["buy_signal"] = ( (df["macd_dif"] > df["macd_dea"]) & (df["macd_dif"].shift(1) <= df["macd_dea"].shift(1)) & (df["close"] <= df["boll_lower"]) ) df["sell_signal"] = ( (df["macd_dif"] < df["macd_dea"]) & (df["macd_dif"].shift(1) >= df["macd_dea"].shift(1)) ) return df逻辑说明:buy_signal 的三个条件里,前两个判断今天 DIF 是否刚从 DEA 下方穿越到上方,第三个要求当天的收盘价不高于布林带下轨。这个组合思路是:金叉提供方向上的转折信号,布林带下轨确认价格处于相对低位,二者同时满足才动手,用来过滤掉高位追涨的假金叉。sell_signal 只保留死叉条件,不在卖出端加过滤,因为卖出更重要的是及时离场。
这里有一个特别容易犯的错误:信号确认时点。上述条件里用到了当天收盘价,你不可能在收盘前就知道收盘价,因此所有信号的实际生效时间都要往后推一天。代码层面统一用 shift(1) 把信号整体后移,这是防止未来函数的底线。在真实交易中,T 日收盘后确认信号,最快也是 T+1 日开盘才能买入,这个时序后面回测章还会再讲。
3.3 可视化验证:matplotlib 画出价格、均线与买卖点
信号算完了,先别急着算收益。我习惯先画图,眼睛里过一遍,很多离谱的问题一眼就能看出来。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码 plt.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块 fig, ax = plt.subplots(figsize=(12, 6)) ax.plot(df["date"], df["close"], lw=1.2, label="收盘价") ax.plot(df["date"], df["boll_upper"], lw=0.8, ls="--", color="gray", label="上轨") ax.plot(df["date"], df["boll_lower"], lw=0.8, ls="--", color="gray", label="下轨") ax.scatter(df.loc[df["buy_signal"], "date"], df.loc[df["buy_signal"], "close"], marker="^", color="red", s=80, label="买入") ax.scatter(df.loc[df["sell_signal"], "date"], df.loc[df["sell_signal"], "close"], marker="v", color="green", s=80, label="卖出") ax.legend() plt.show()逻辑说明:这是一个最基础的双轴图,价格线画在主轴上,买点用红色上三角标在对应日期的收盘价位置,卖点用绿色下三角标。subplots 里的 figsize 控制画布大小,lw 是线宽,ls 是线型,s 是散点大小。可视化这一步看似简单,但价值在于用肉眼检查信号位置是否合理——比如买入信号是不是密集出现在下跌趋势的左侧,如果是,说明过滤条件没有真正起作用,需要回去调策略逻辑而不是继续往下做回测。
运行环境需要安装 matplotlib 和 pandas,同时确认系统里有中文字体,否则图上的中文会显示成方块。macOS 上一般把 SimHei 换成 PingFang SC 或 Arial Unicode MS,Linux 上可以用 Noto Sans CJK。
4. 回测验证:让信号在历史数据里先跑一遍
4.1 向量化回测:净值曲线与最大回撤这样算
信号只是过程,收益才是结果。回测要做的事情是回答一个问题:按这套信号买卖,在历史行情里能赚多少钱、回撤有多大。
def run_backtest(df: pd.DataFrame, cost_rate: float = 0.0013) -> pd.DataFrame: """向量化回测,假设 T 日收盘确认信号、T+1 日开盘成交。""" df = df.copy() df["signal"] = 0 df.loc[df["buy_signal"], "signal"] = 1 df.loc[df["sell_signal"], "signal"] = -1 # 持仓状态:买入后持有,卖出后空仓;遇到相反信号直接翻转 position = 0 positions = [] for s in df["signal"]: if s == 1 and position == 0: position = 1 elif s == -1 and position == 1: position = 0 positions.append(position) df["position"] = positions df["position"] = df["position"].shift(1) # 信号次日才生效 df["ret"] = df["close"].pct_change().fillna(0) df["turnover"] = df["position"].diff().abs().fillna(0) df["strategy_ret"] = df["ret"] * df["position"] - df["turnover"] * cost_rate df["nav"] = (1 + df["strategy_ret"]).cumprod() df["benchmark_nav"] = (1 + df["ret"]).cumprod() return df逻辑说明:这里的处理分三层。第一层,把买卖信号转成目标仓位,1 表示满仓,0 表示空仓。第二层,用 shift(1) 把整个仓位序列后移一天,这模拟的是“收盘后看到信号,第二天才成交”,这是回测中最关键的一步,没做这一步的回测结果一律偏高。第三层,计算每日策略收益,用当日收益率乘以昨日持仓,如果当天发生了仓位变化,还要扣掉对应的交易成本。
这里需要说明的是,生成持仓的循环跑在信号序列上,几千行数据基本瞬间完成。真正的向量化体现在收益率和净值计算这一段,pct_change、diff、cumprod 都是按整列操作的,这个写法比逐行循环算收益快非常多。cost_rate 是单次换仓的总成本,默认 0.0013,含义是买入卖出合计的成本估算,后面小节会细拆。
最大回撤是衡量策略风险的核心指标,单独写个小函数:
def max_drawdown(nav: pd.Series) -> float: """最大回撤:从历史最高点到后续最低点的最大跌幅。""" peak = nav.cummax() return ((nav - peak) / peak).min()这个函数的逻辑是从净值序列的第一个值开始,不断记录当前位置之前的最高净值,然后计算当前净值相对最高净值的跌幅,取所有跌幅里的最小值。A 股策略里回撤超过 30% 大部分人拿不住单,所以这个指标比年化收益更能说明策略的实操价值。
4.2 手续费与滑点的估算:两个容易被忽略的成本
很多初版回测把交易成本设成 0,回测净值看着漂亮,一实盘就缩水。这是回测和实盘差距最大的地方之一,必须单独拎出来说。
A 股的交易成本大致由佣金和印花税组成。佣金双边收取,默认费率在万 2.5 到万 3 之间,买卖都扣;印花税只在卖出时收取,2023 年 8 月之后减半至千分之 0.5。把这两个加在一起,一次完整的买入再卖出,成本大约在千分之 1.3 左右。这个值就是前面 cost_rate=0.0013 的来源。
| 成本项 | 默认取值 | 说明 |
|---|---|---|
| 佣金 | 0.00025 | 双边收取,可和券商协商下调 |
| 印花税 | 0.0005 | 卖出单边收取 |
| 滑点 | 0.0002~0.001 | 实际成交价与信号价的偏差 |
| 合计 | 约 0.0013 | 一次买入再卖出的总成本估算 |
滑点是更难估的一块。回测里默认按收盘价成交,但现实里你会在买盘上排队,买不到或者买得更贵是常态。保守的做法是在 cost_rate 里再留一点余量,比如从 0.0013 提到 0.0015。宁可把成本算高,让回测结果难看一点,也比实盘收益低于回测要好。我一般先按 0.0013 跑一遍,再用 0.002 跑一遍做压力测试,两个结果差距很大时,说明策略的交易频率偏高,对成本过于敏感,这种策略在实盘里很难做好。
4.3 参数网格搜索与过拟合陷阱
MACD 的 12/26/9、布林带的 20 日 2 倍标准差,都是默认参数,不是最优参数。优化参数最常见的手段是网格搜索,把几个关键参数组合起来逐个跑回测,挑收益最好的一组。
import itertools candidates = [(6, 13, 5), (12, 26, 9), (10, 22, 7), (8, 17, 6)] for fast, slow, signal in candidates: test = add_indicators(raw_df.copy(), fast=fast, slow=slow, signal=signal) test = generate_signals(test) result = run_backtest(test) final_nav = result["nav"].iloc[-1] mdd = max_drawdown(result["nav"]) print(f"MACD({fast},{slow},{signal}) 净值 {final_nav:.3f} 回撤 {mdd:.2%}")逻辑说明:这段代码把四组 MACD 参数循环跑一遍,输出每组参数的最终净值和最大回撤。candidates 是参数候选列表,前两个值是快慢均线周期,第三个值是 DIF 的平滑周期。网格搜索本身没有技术难度,真正的坑在后面:如果你直接选净值最高的一组参数,大概率是在过拟合历史数据。
同样的参数换个时间区间,可能从最优变成最差。我常用的验证手段是把数据切成两段,比如前 70% 用作用来挑参数的训练段,后 30% 作为验证段。参数在训练段里选好,之后拿到验证段跑一遍,如果验证段收益明显缩水,说明参数在适配历史噪声,策略本身可能没有真实优势。网格搜索不是不能用,但它只能回答“过去哪些参数表现好”,回答不了“未来会怎样”,这一点想清楚了,调参才不会变成纯玄学。
5. 常见问题与避坑:复权、时区、T+1 与信号漂移
5.1 前复权还是后复权:除权除息日信号突然失灵
现象:某只股票的分红除权日前后,价格出现一个莫名其妙的向下跳空,MACD 跟着走出极端值,信号在除权日附近频繁触发。
原因:拿到的是不复权数据。股票分红送股会在除权日把股价机械性下调,比如 10 元股价每 10 股派 5 元,除权后股价直接变成 9.5 元,这不代表真实亏损,但价格序列就此断裂,基于价格计算的指标也就失真了。如果没有处理复权,回测里会算出很多假的买入信号。
解决:统一用前复权数据。具体做法是拉数时把接口的 adjust 参数设为 qfq,也就是前复权。前复权的好处是让整个历史区间的价格保持连续,适合回测场景;代价是前复权价格会随着最新行情而整体平移,所以同一天的历史价在不同时间点拉回来会略有差异,这是正常的,不是数据出错了。关键是,同一批数据里的复权口径必须一致,不能在策略里混用前复权和不复权数据。
5.2 时区与缓存:盘中拉数据为什么对不上
现象:白天盘中拉到的日线数据和行情软件对不上,收盘后再拉就正常了。
原因:盘中接口的行为和收盘后不一样。日线接口在盘中会返回未完结的当日数据,某些字段的含义也会从“已收盘数据”切换成“实时快照”,部分数据源还会在前后端加缓存,导致你拿到的数据可能滞后几分钟。
解决:做日线分析时明确约定只用收盘后的数据去算指标、生成信号。我在实践中给数据文件加上更新时间戳,每次写入 parquet 前检查当天数据是否已经存在,存在就不再重复拉取。这样至少从流程上限制了盘中数据污染。如果你确实想盘中观察,就把盘中数据单独存,不要直接覆盖日线文件。
5.3 T+1 与涨跌停:回测净值为何过于乐观
现象:回测年化收益很高,但实盘买入时经常买不进去,卖出时卖不出来,最终收益和回测差一大截。
原因:A 股实行 T+1 交易制度,当天买入的股票当天不能卖出;同时涨跌停制度导致极端行情下单子排不进去。回测代码默认每一笔交易都能按收盘价成交,完全忽略了这两个限制。
解决:在回测里做两层修正。第一层,买入后最低持仓一天,信号当天不生效;前面 run_backtest 里的 shift(1) 已经部分覆盖了这个语义。第二层,加涨跌停过滤,当日涨幅接近涨停时不生成买入信号,跌幅接近跌停时不生成卖出信号,代码里可以用收盘价相对前一日收盘价的涨跌幅来判断。过滤条件加了之后,回测的交易次数会减少,净值也会下修,但这是接近真实交易的必要代价。
5.4 信号漂移与未来函数:最大的翻车点
现象:回测曲线完美,一到实盘就崩,怎么排查都找不到原因。
原因:最典型的是用了未来数据,也就是前视偏差。比如当天的买入信号用了当天收盘价,但你在当天开盘时根本不知道收盘价是多少;又比如用今天的全量数据去计算昨天的指标,导致昨天的信号被事后修正了。这些错误在回测代码里很难看出来,因为回测是在整段历史数据上一次性算完的,数据天然都是“已知”的。
解决:给所有信号统一加 shift(1),强制把信号生效时间后移一天。这是我从踩坑里总结出的铁律:任何基于收盘价计算出来的信号,最早只能在次日交易,这条规则在代码里必须显式写出来,不能靠心里默念。写完信号逻辑后,可以做一个自检:挑最近 10 个信号,逐一检查信号日的最高最低价,如果发现某个信号当天的最低价出现在收盘价之后,说明代码里还有前视逻辑在偷偷起作用。
6. 进阶技巧:把分析和交易分开,构建增量更新闭环
6.1 写一个每日自动更新的脚本入口
建立完整框架之后,我不建议把分析和交易混在一个脚本里跑。一个比较稳的用法是每日收盘后跑一次数据更新和信号刷新,交易决策交给单独的流程去处理。增量更新可以拆成三步:拉当日行情、追加到本地 parquet、重新计算指标并输出当日信号。
from datetime import datetime def update_daily(symbol: str) -> pd.DataFrame: """每日收盘后调用:拉新增日线,合并入库,输出最新信号。""" today = datetime.now().strftime("%Y%m%d") new = load_daily(symbol, start=today, end=today) save_parquet(new, f"data/{symbol}.parquet") full = pd.read_parquet(f"data/{symbol}.parquet") full = add_indicators(full) full = generate_signals(full) print(f"{symbol} 最新信号: {full[full['date'] == full['date'].max()][['close', 'buy_signal', 'sell_signal']]}") return full这个函数的思路是把流程固定下来:先拉当日数据,再由 save_parquet 做去重合并,最后用全量数据重新计算指标。重新计算指标这一步看起来重复劳动,但比增量计算省力的多,也更容易保证结果正确,数据量在日线级别完全扛得住。
6.2 用净值快照监控策略漂移
我的习惯是定期记录回测净值和实际持仓净值的差距,做一个简单的对照表。如果回测净值在涨,而实盘持仓净值在跌,通常不是运气问题,而是实际成交价和回测假设发生了系统性偏差,要去检查滑点估算或者信号执行是否出了问题。建一个名为 strategy_log.csv 的文件,每次更新时写入当日净值、持仓状态、信号状态,时间一长就能看出这个策略在真实环境里是否活着。落地这套闭环最踏实的一点是,每天只需要几分钟的定时任务,但累积下来的运行日志会让你对策略的信任程度有据可查。
我现在的做法依然是:回测永远只作为参考,盘中不做决策,收盘后再让脚本跑一遍。这个顺序帮我过滤掉了大量盘中冲动操作带来的损失。希望这个最小流程能帮你在股市分析这条路上少走几步弯路,祝顺利。
本文还有配套的精品资源,点击获取