简介:这份《基于股票大数据分析Python实战》面向希望用Python打通金融数据采集、清洗、分析与预测链路的学习者,重点覆盖爬虫抓取股票行情、公告与资讯,以及后续的数据预处理和建模实践。内容围绕BeautifulSoup、Scrapy等采集工具,Pandas缺失值与异常值处理,NumPy/SciPy统计计算,Matplotlib/Seaborn绘制K线、成交量和箱型图展开,并延伸到线性回归、决策树、随机森林、神经网络及Scikit-learn预测流程,同时涉及Tushare、Wind实时数据接口和Flask/Django结果展示。压缩包约4.47MB,文件总数显示为0、类型明细暂缺,上游未提供具体目录,需以下载后的实际内容为准。已有3659人学习,适合具备Python基础、想通过项目案例理解股票大数据分析流程的读者查漏补缺,也可作为金融数据分析入门到实战的参考素材。
1. 需求切入
股票大数据分析这套东西,很多人第一反应是找个现成客户端把数据导成表格,再拉个图看看趋势,但在真正动手之后,最先卡住的往往不是分析,而是数据本身——字段对不上、复权口径不一致、时间戳错位,一通操作下来结论全是玄学。基于 Python 的股票大数据分析实战,核心价值并不在于算法花哨,而在于能不能自己搭起一条从爬虫采集、清洗入库、指标计算到回测验证的完整链路,让每一个结论都能追回到原始记录。这套结构适合要做数据分析类毕设、课程设计的学生,也适合手里已经有一堆零散脚本、想整理成可交付流水线的从业者。下面的章节按能直接照着复现的顺序展开:先把数据采稳,再把表建对,然后做指标和回测,最后讲怎么验收。
2. 数据采集层设计:从定时请求到本地落库的完整链路
采集层的目标不是“抓到数据”,而是“每天抓到的数据和昨天抓到的能拼在一起”。这一层如果设计得草率,后面清洗和回测阶段就得反复补数据,踩坑成本极高。所以这里按四件事来拆:采什么、怎么限速重试、怎么断点续采、落地后怎么校验。
2.1 采集目标分级与调度策略
一个完整的分析项目通常需要四类数据,优先级差别很大:
| 数据类别 | 用途 | 采集频率 | 优先级 |
|---|---|---|---|
| 日线行情(开高低收、成交量额) | 指标计算与回测主表 | 每交易日收盘后一次 | 必需 |
| 复权因子 | 计算前复权/后复权价格 | 与日线同步 | 必需 |
| 交易日历 | 判断是否开市、校验数据完整性 | 每周一次 | 必需 |
| 板块成分与行业分类 | 分组统计、行业对比 | 每月一次 | 可选 |
优先级的意义在于容错:必需类数据缺一天,当天的分析就要中断;可选类缺一周,最多是分组维度少一列,不影响主流程。实际排期时,先让必需类跑通一个月,再补可选类,比一上来全部铺开稳得多。
调度一般采用两种方式。本地开发用 APScheduler 挂在脚本里,配置 cron 触发器在收盘后固定时间点执行;部署到服务器就交给系统级 cron 或者任务队列,脚本本身只负责“采一次”,不负责“什么时候采”。这个边界一定要划清,否则调试时你会分不清是调度没触发还是采集逻辑报错。交易日判断放在采集脚本入口,先查交易日历,非交易日直接退出并打一条日志,避免节假日空跑产生大量无效请求。
2.2 请求层的限速、重试与增量游标
采集脚本最常见的翻车方式是并发开太大,请求被限流后成片返回错误,脚本却不报错,最后落库的是残缺数据。稳妥的写法是单会话复用连接、显式限速、指数退避重试,并把“采到哪一天”记成游标持久化。
import json import time import random import logging from pathlib import Path import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logger = logging.getLogger("collector") # 会话复用 + 连接池,避免每次请求重建 TCP 连接 session = requests.Session() retry_policy = Retry( total=3, # 最多重试 3 次 backoff_factor=1.5, # 退避:1.5s、3s、6s status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"], ) session.mount("https://", HTTPAdapter(max_retries=retry_policy, pool_maxsize=8)) session.headers.update({"User-Agent": "data-pipeline-demo/1.0"}) CURSOR_FILE = Path("data/cursor.json") def load_cursor() -> dict: """读取上次采集进度,不存在则从空开始""" if CURSOR_FILE.exists(): return json.loads(CURSOR_FILE.read_text(encoding="utf-8")) return {} def save_cursor(cursor: dict) -> None: CURSOR_FILE.parent.mkdir(parents=True, exist_ok=True) CURSOR_FILE.write_text(json.dumps(cursor, ensure_ascii=False), encoding="utf-8") def fetch_daily(symbol: str, start: str, end: str, min_interval: float = 0.35, max_interval: float = 0.8) -> list: """拉取单只标的的日线区间数据,带随机间隔与失败降级""" url = "https://example-market-api.local/daily" params = {"symbol": symbol, "start": start, "end": end} try: resp = session.get(url, params=params, timeout=(5, 15)) resp.raise_for_status() return resp.json().get("items", []) except requests.RequestException as exc: # 单只失败不中断整轮,记录下来后续补采 logger.warning("fetch failed symbol=%s err=%s", symbol, exc) return [] finally: # 随机间隔,避免固定频率造成请求尖峰 time.sleep(random.uniform(min_interval, max_interval))这段代码里有三个关键设计。连接池和重试策略解决的是网络抖动的偶发失败,total=3配合backoff_factor=1.5意味着连续失败后等待时间递增,给对端留出恢复窗口,而不是把重试变成变相的密集请求。随机间隔uniform(0.35, 0.8)比固定sleep(0.5)更接近人工访问节奏,能显著降低被判定为高频访问的概率。finally块保证无论成功失败都消耗一次间隔时间,防止异常分支绕过限速形成突发流量。
游标则决定了这套脚本能不能每天增量跑。第一次跑全量历史,把每只标的的最后成功日期写进cursor.json;之后每次只请求上次日期 + 1 天到今天,请求量从几千次降到个位数。补采逻辑单独写一个函数,读日志里记录的失败标的列表,逐只重跑,跑完从列表里剔除。
2.3 原始落地的目录规范与入口校验
原始数据不要直接进数据库,先按日期分区落到磁盘,保留一份“未经任何加工”的副本。这算是整个项目的后悔药——清洗规则改十遍,原始数据也还在。
目录按data/raw/daily/trade_date=YYYY-MM-DD/组织,每个分区里放一个 CSV 或 Parquet,文件名带采集批次号,重复采集时新批次覆盖同名分区,天然幂等。落盘前做四类入口校验,任何一条不通过就把这批数据标记为脏数据、单独放隔离目录,不污染主分区:
- 行数校验:当日返回的标的数量与交易日历里的应有数量偏差超过 5% 就告警,通常是接口分页没取全。
- 字段非空:开高低收、成交量五个核心字段不允许为空。
- 数值合理性:最高价不低于最低价,收盘价落在当日最高最低之间,价格与成交量均大于零。
- 时间一致性:所有记录的交易日字段必须等于分区日期,防止接口返回错位数据。
这四条写成一个validate_raw(df, trade_date)函数,返回校验报告字典,采集脚本根据报告决定写入主分区还是隔离区。校验报告本身也落盘,答辩时它就是“数据质量可追溯”的直接证据。
3. 清洗与存储建模:把杂乱原始行情整理成可计算的宽表
采集层交给下游的是“接近原始”的数据,字段名五花八门、单位不统一、复权口径混着来。清洗层要交付的是一张能直接算指标的宽表:一行对应一个标的的一个交易日,列是标准化的价格、成交量、复权因子、行业分类。这一层的设计质量,直接决定回测结果可不可信。
3.1 复权口径统一与单位归一
复权是这个项目里最容易出问题的地方。不复权价格在除权除息日会出现断崖,用它算出来的均线、收益率全是错的;前复权价格以当前为基准回溯调整,历史数据会随每次分红而变化,今天算出来的回测结果和上个月算的不一样;后复权以上市首日为基准,历史值稳定,适合做长周期回测。我的做法是:主表存量价数据与复权因子两套列,价格列一律存不复权原始值,另存adj_factor因子列,需要哪种口径在下游现算,绝不把某种复权价格固化进主表。
单位归一同样不能省。不同来源的成交量有的是“手”有的是“股”,成交额有的带千元单位,混在一起算换手率会差出两个数量级。清洗阶段统一约定:成交量单位股、成交额单位元、价格单位元,所有字段在列名后缀里标注单位,比如volume_share、amount_cny,谁读表都不会误解。
import pandas as pd def normalize_daily(df: pd.DataFrame) -> pd.DataFrame: """标准化列名、单位与类型,输出统一口径的日线表""" df = df.rename(columns={ "code": "symbol", "trade_date": "trade_date", "open": "open_raw", "high": "high_raw", "low": "low_raw", "close": "close_raw", "vol": "volume_share", "amount": "amount_cny", }) # 成交量若以“手”为单位,乘以 100 归一到“股” if "volume_unit" in df.columns: mask = df["volume_unit"] == "lot" df.loc[mask, "volume_share"] = df.loc[mask, "volume_share"] * 100 num_cols = ["open_raw", "high_raw", "low_raw", "close_raw", "volume_share", "amount_cny", "adj_factor"] for col in num_cols: df[col] = pd.to_numeric(df[col], errors="coerce") df["trade_date"] = pd.to_datetime(df["trade_date"], format="%Y%m%d") df["symbol"] = df["symbol"].astype("string").str.zfill(6) return df.sort_values(["symbol", "trade_date"]).reset_index(drop=True)errors="coerce"把无法解析的值转成缺失而不是直接抛异常,配合后面的缺失统计能定位到具体是哪几行脏;str.zfill(6)补齐标的后缀前导零,避免000001被读成整数1导致主键错乱——这个坑几乎每个做股票数据的人都踩过一次。排序不能省,后续所有滚动窗口计算都依赖时间有序。
3.2 缺失值与停牌处理规则
停牌日的处理要分场景。做指标计算时,停牌日本来就没有价格,强行前向填充会造出一段“假横盘”,把均线拉平、把波动率压低;做组合收益统计时,停牌期间持仓是冻结的,收益应该按 0 计而不是按缺失丢弃。
我的处理规则是:主表不为停牌日造行,只在交易日历与标的实际数据做左连接时产生缺失;指标计算阶段按标的做groupby后ffill(limit=5),最多容忍连续 5 个交易日缺失,超过则判定为长期停牌并从该标的的有效区间里截断;收益计算阶段用“上一个有效收盘价”到“当前有效收盘价”的区间收益,把停牌期间的收益摊到复牌日一次性体现。这套规则写进一个build_price_panel()函数,参数max_gap控制容忍天数,默认 5,长周期策略可以调到 20。
3.3 分区存储与查询性能
数据量到千万行级别后,CSV 就 Handling 不动了。存储方案选 Parquet + 分区目录,按year=YYYY/month=MM两级分区,单文件控制在几十 MB,配合 DuckDB 或 pandas 的read_parquet直接做谓词下推,只扫需要的分区。
import duckdb # 按年月分区写入,后续查询只扫命中分区 con = duckdb.connect() con.execute(""" COPY (SELECT * FROM daily_panel) TO 'data/warehouse/daily' (FORMAT PARQUET, PARTITION_BY (year, month), OVERWRITE_OR_IGNORE) """) # 回测取数:只读指定年份区间,避免全表扫描 bt_df = con.execute(""" SELECT symbol, trade_date, close_raw, adj_factor, volume_share FROM read_parquet('data/warehouse/daily/**/*.parquet') WHERE year BETWEEN 2021 AND 2023 AND symbol IN (SELECT symbol FROM universe) ORDER BY symbol, trade_date """).df()分区字段year、month在写入前由trade_date派生,不要指望存储层自动推断。OVERWRITE_OR_IGNORE保证同一天重跑不会产生重复文件,这是幂等性的第二道保险。实际测试中,带分区过滤的查询比全表扫描快一个数量级,在本地机器上就能跑完三年全市场的回测,不需要上集群。
4. 指标计算与策略分析:从均线到回测的指标工厂
有了干净的宽表,指标层要做的是把“人看的图”翻译成“机器能算的数”。这一层的设计原则是向量化、可配置、可复现:指标参数写进配置而不是散落在代码里,计算过程全部走 pandas 向量化操作,任何一个指标都能被单独取出与手工计算对账。
4.1 指标向量化实现与参数配置
均线、MACD、RSI、布林带是最基础的四类,覆盖了趋势、动量、波动三个维度。实现时统一放在一个indicators.py里,每个函数接收DataFrame返回带新列的DataFrame,参数通过字典传入。
import numpy as np import pandas as pd def add_ma(df: pd.DataFrame, windows=(5, 20, 60)) -> pd.DataFrame: """简单移动平均,按标的独立计算,避免跨标的串窗""" for w in windows: df[f"ma{w}"] = (df.groupby("symbol")["close_raw"] .transform(lambda s: s.rolling(w, min_periods=w).mean())) return df def add_macd(df: pd.DataFrame, fast=12, slow=26, signal=9) -> pd.DataFrame: """MACD:快慢EMA之差,再对DIF做EMA得到DEA""" def _macd(s: pd.Series) -> pd.DataFrame: ema_fast = s.ewm(span=fast, adjust=False).mean() ema_slow = s.ewm(span=slow, adjust=False).mean() dif = ema_fast - ema_slow dea = dif.ewm(span=signal, adjust=False).mean() return pd.DataFrame({"dif": dif, "dea": dea, "hist": (dif - dea) * 2}) parts = df.groupby("symbol")["close_raw"].apply(_macd).reset_index(level=0) return df.merge(parts, on=["symbol", "trade_date"], how="left") def add_rsi(df: pd.DataFrame, window=14) -> pd.DataFrame: """RSI:用Wilder平滑,注意分母为0时返回NaN而不是inf""" def _rsi(s: pd.Series) -> pd.Series: delta = s.diff() gain = delta.clip(lower=0).ewm(alpha=1 / window, adjust=False).mean() loss = (-delta.clip(upper=0)).ewm(alpha=1 / window, adjust=False).mean() rs = gain / loss.replace(0, np.nan) return 100 - 100 / (1 + rs) df[f"rsi{window}"] = (df.groupby("symbol")["close_raw"] .transform(_rsi)) return df三个函数都强制按symbol分组,这是核心约束。如果不分组直接对整表滚动,窗口会跨标的串联,算出来的均线毫无意义,而且这种错误不会报异常,只会让回测收益变得异常漂亮,属于最难排查的一类问题。min_periods=w保证前 w-1 行是 NaN 而不是用不足窗口的数据硬算,ewm(adjust=False)是 MACD 与 RSI 的通行口径,换成adjust=True结果会与主流软件对不上,对账时非常痛苦。RSI 里loss.replace(0, np.nan)处理连续上涨导致分母为零的情况,直接相除会得到 inf,后续任何统计都会被污染。
4.2 回测骨架:信号、成交与成本
回测最容易出的问题是未来函数和成本缺失。骨架必须显式规定三件事:信号在哪一天产生、按哪一天的价格成交、每次交易扣多少成本。我的约定是信号在 T 日收盘后产生,T+1 日开盘价成交,这样才符合“收盘后才知道信息”的现实约束。
def backtest(panel: pd.DataFrame, fee_rate=0.0003, stamp_tax=0.001, slippage=0.001) -> pd.DataFrame: """单标的双均线回测,T日出信号、T+1开盘成交""" df = panel.sort_values("trade_date").copy() # 信号:短均线上穿长均线为1,下穿为0 df["signal"] = (df["ma5"] > df["ma20"]).astype(int) # 关键一步:信号右移一格,明天才能按今天的信号交易 df["position"] = df["signal"].shift(1).fillna(0) # 成交价用次日开盘价,叠加滑点 df["exec_price"] = df["open_raw"] * (1 + slippage) df["ret_market"] = df["close_raw"].pct_change() # 持仓收益:昨日仓位 × 今日市场收益 df["ret_strategy"] = df["position"].shift(1).fillna(0) * df["ret_market"] # 换仓成本:仓位变化时扣除双边费用 turnover = df["position"].diff().abs().fillna(0) cost = turnover * (fee_rate * 2 + stamp_tax) df["ret_net"] = df["ret_strategy"] - cost df["nav"] = (1 + df["ret_net"]).cumprod() return dfsignal.shift(1)这一行是整个回测正确性的分水岭。少了它,等于用当天收盘价判断、又用当天收盘价成交,回测收益能高得离谱,而这种虚高在图上看起来特别顺滑,是典型的自欺欺人。position.shift(1)再乘市场收益,对应“昨天持有到今天”的真实暴露。成本部分双边手续费加卖出印花税,滑点按成交价的千分之一计入,参数全部外置,方便做敏感性分析——把滑点从 0.1% 调到 0.3%,如果策略从盈利翻成亏损,那这个策略的鲁棒性就该打问号。
4.3 结果汇总与图表输出
回测跑完不是看一条净值曲线就完事,要输出一套固定指标:年化收益、最大回撤、夏普比率、胜率、盈亏比、换手率。这些指标统一由一个summarize(nav_series)函数计算,返回字典,多个策略的结果拼成一张对比表落成 CSV。
图表用 matplotlib 输出三张:净值曲线与基准对比、回撤曲线、月度收益热力图。样式统一在脚本开头设定,图保存到reports/figures/,文件名带策略名与参数组合,比如ma_5_20_slip0.001.png。参数组合命名很重要,做了二十组参数实验后,你会感谢自己当初在文件名里写了参数。
5. 避坑与排查:五类最容易翻车的数据问题
这一章列的是项目里出现频率最高、且一旦发生就很难靠肉眼发现的问题。每一条都按“现象、原因、解决”走一遍,值得在开发阶段就做成检查项。
5.1 未来函数:回测收益好得不真实
现象:策略年化收益几十个点,净值曲线几乎不回撤,实盘一跑就亏。
原因:信号计算和成交用了同一天的价格,或者用了当天的最高最低价做成交价,等于提前知道了当天走势。
解决:所有信号统一shift(1)后再参与收益计算;成交价只用开盘价或收盘价,绝不用当日最高最低;写一个检查函数,断言position列的第 t 行只能依赖 t-1 及之前的信息,把这个断言放进回测入口,跑之前先过一遍。
5.2 复权口径混用:收益率凭空多出几个点
现象:同一策略,换一份数据源重跑,年化收益差出十几个点。
原因:一份数据是前复权,一份是不复权,除权日价格断层被当成了真实跌幅或涨幅。
解决:主表只存不复权价格加复权因子,复权在下游统一切换;写对账脚本,随机抽十只标的,把复权后价格与数据源提供的复权价做比对,偏差超过 0.1% 就报警。
5.3 幸存者偏差:样本里只剩活下来的标的
现象:全市场选股策略表现优异,但换到某个时间段就失效。
原因:标的池用的是“当前上市名单”,退市、暂停上市的标的被自动排除,等于提前剔除了失败样本。
解决:标的池按交易日的时点成分构建,每个回测日只用当日实际存在交易的标的;历史退市标的的数据也要保留在库里,只是标记状态字段。
5.4 重复采集与主键冲突:行数对不上
现象:某天的数据行数是正常值的两倍,指标计算结果出现重复日期。
原因:采集重跑时追加写入而非覆盖,或者同一标的被两个批次都写进了分区。
解决:落库前按(symbol, trade_date)去重并保留最后一批;写入使用覆盖模式;每日跑完后执行一次主键唯一性检查,重复则整批回滚重采。
5.5 交易日历错位:节假日与半天交易
现象:某些日期数据为空被判定为采集失败,实际是当天不开市;或者半天交易日的成交量统计明显偏低。
原因:用自然日代替交易日,或者交易日历没有覆盖特殊安排。
解决:所有日期循环以交易日历为准,不使用pd.date_range的自然日序列;交易日历单独维护一张表,包含是否交易、是否半天交易两个标志位,采集与分析入口都先查这张表。
6. 验收与答辩:怎么证明这套分析是可信的
验收这件事,核心不是展示图画得多漂亮,而是回答一个问题:凭什么相信你的结论。答辩场景下,评审最常追问的是数据来源、清洗规则、回测假设这三块,只要每一块都能拿出可复现的证据,基本就稳了。
先准备一张验收清单,逐项自检:
| 验收项 | 合格标准 | 证据形式 |
|---|---|---|
| 数据完整性 | 近三年交易日覆盖率不低于 98% | 交易日历左连接后的覆盖率报告 |
| 数据准确性 | 随机抽取 20 个标的日,与原始来源逐字段核对一致 | 对账明细表 |
| 复权正确性 | 复权后价格与来源复权价偏差小于 0.1% | 偏差统计 CSV |
| 指标可复现 | 手工计算 5 日均线与程序结果一致 | 对照表截图或笔记 |
| 回测无未来函数 | 信号全部滞后一日,断言通过 | 单元测试输出 |
| 成本假设明确 | 手续费、印花税、滑点三项参数写进配置文件 | 配置片段 |
| 结果可追溯 | 每组参数对应独立的图表与指标文件 | 报告目录结构 |
有了清单,答辩时按“数据怎么来的、清洗怎么做的、回测怎么假设的、结果怎么验证的”四段讲,每段配一张表或一段代码,控制在八分钟内。被问到“你的策略能不能实盘”,标准回答是先讲清楚回测的简化假设:没有考虑涨跌停无法成交、没有考虑停牌期间的资金占用、没有考虑大单冲击成本,这些都会让实盘表现低于回测,承认边界比硬撑更可信。
还有一个容易被忽略的技巧:把每天跑完流水线后自动生成的日志留好。日志里记录当天采集了多少标的、校验失败几条、指标计算耗时多少。答辩现场如果被质疑数据量,直接翻日志比解释半天有用。
我自己在这个项目上翻过一次车:早期为了省事,采集脚本和清洗脚本写在同一个文件里,跑了两周后发现某几天的数据对不上,回头查的时候已经分不清是采集漏了还是清洗覆盖了。从那以后我每次搭这类流水线,都强制走一遍“原始层只增不改、清洗层可全量重跑、指标层参数外置”的三层隔离,哪怕项目再小也不合并。希望这套结构能帮到你,把自己那份分析做成别人敢信、自己也敢复现的东西。
本文还有配套的精品资源,点击获取