1. 项目背景与目标设定
先说清楚这个项目到底在做什么。标题里写的是"Python量化炒股分析:多角度解析股价与价值背离及预测行情涨跌",拆开来看,核心就三件事:第一,用Python把股票数据拉下来;第二,从多个维度去判断股价和它的内在价值到底有没有偏离、偏离了多少;第三,基于这些偏离信号,尝试对行情的涨跌方向做预测。
听起来好像很玄乎,其实底层逻辑并不复杂。做过几年交易或者看过一些基本面分析资料的人都知道,股价短期是投票器,长期是称重机。意思是短期内市场情绪、资金博弈、消息面会把价格推到离价值很远的地方,但拉长时间看,价格终究要向价值回归。这个项目做的就是用程序化的方式,把这个"偏离"和"回归"的过程量化出来,变成可回测、可复现、可执行的信号。
适合谁来参考?说实话,门槛不算低,但也不需要你是量化大神。我假设你已经会基本的Python语法,知道pandas怎么操作DataFrame,装过jupyter或者vscode,跑过最简单的爬虫或者接口请求。如果你连这些还没搞定,建议先去把Python基础过一遍再来,不然看后面的代码会比较吃力。反过来,如果你已经有一定交易经验,但对"怎么把想法变成代码"还不太熟,这篇文章也能给你一条比较完整的参考路径。
这个项目的价值不在于给你一个"稳赚不赔"的策略——我可以直接说,市面上不存在这种东西。它的价值在于:给你一套完整的思路框架和可运行的代码骨架,让你知道如何用数据去验证自己对市场的判断,而不是凭感觉下单。
2. 整体方案设计与数据获取
2.1 技术方案选型:为什么是Python
做量化分析,语言选择上无非就是Python、R、Matlab,甚至现在也有人用JavaScript或者Go写交易系统。但我个人还是推荐Python,原因很直接:
第一,生态成熟。你想用的任何金融数据库、技术指标库、机器学习库,Python基本都有现成的。akshare、tushare、baostock、yfinance这些数据源接口覆盖了A股、港股、美股、期货、加密货币等几乎所有主流市场,不需要自己去写爬虫解析乱七八糟的HTML。pandas、numpy处理数据的能力更是没得说,一行groupby就能完成Excel里折腾半天的聚合统计。
第二,策略验证效率高。从数据清洗到因子计算,再到回测和可视化,Python的整个工作流非常顺畅。我见过有人用Excel做回测,数据一多就卡死,更别提做参数敏感性分析了。Python在这方面的优势是碾压性的。
第三,社区资料丰富。遇到问题随便一搜都能找到解决方案,尤其是量化这个细分领域,GitHub上有大量开源项目可以参考。学习成本相对低。
当然,Python也有短板,就是纯计算性能比C++差很多。但对个人做研究级别的回测来说,这个短板根本感知不到。真到了需要高频交易或者分钟级甚至tick级大规模回测的时候,可以再用Cython、Numba优化,或者直接用C++重写核心逻辑,这都不是问题。
2.2 数据源对比与选型建议
数据是整个项目的粮食,数据质量直接决定分析结果靠不靠谱。我常用的是akshare和tushare,这里做一个对比:
| 数据源 | 免费额度 | 数据范围 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| akshare | 完全免费 | 沪深京A股、港股、美股、期货、基金、宏观数据 | 中等,接口偶尔变动 | 个人研究、教学演示 |
| tushare | 注册送积分,积分越高权限越大 | A股、港股、美股、期货等 | 较好,接口稳定 | 个人研究、小规模策略 |
| baostock | 完全免费 | A股日线/分钟线、指数、行业 | 较好 | 需要长时间历史数据回测 |
| yfinance | 完全免费 | 美股、部分港股 | 一般,反爬较严 | 美股研究 |
我自己的偏好是:研究A股用tushare的日线数据,因为数据质量相对可靠,复权因子处理得比较规范。如果只是快速验证想法,akshare最方便,不用注册账号就能用。注意,akshare的接口偶尔会因为上游网站改版而出问题,代码里最好加上异常重试机制,否则跑数据跑到一半挂了会很崩溃。
以tushare为例,获取数据的基本流程是:
import tushare as ts import pandas as pd # 注册tushare账号后获取token ts.set_token('你的token') pro = ts.pro_api() # 获取某只股票最近5年的日线数据 df = pro.daily(ts_code='600519.SH', start_date='20200101', end_date='20241231') # 获取基本面数据(这里用市盈率、市净率等指标) # 先从股票列表里拿到基础信息 basic_info = pro.stock_basic(ts_code='600519.SH', fields='ts_code,name,industry,list_date') print(basic_info)日线数据拿到手之后,先把日期转成datetime类型并设为索引,按时间升序排列,这一步看起来简单但特别重要,很多新手拿到数据就开算,结果因为顺序不对,算出来的移动平均线全是错的。
2.3 数据清洗与预处理规范
原始数据不能直接用,必须经过清洗。我一般按以下步骤处理:
- 去重:检查是否有重复的日期记录,尤其是从多个数据源拼接数据时容易出现。
- 缺失值处理:停牌日的数据往往是NaN,需要forward fill或者直接删除。对于技术指标计算,建议先fillna再计算,否则ma5、ma10这种指标会断掉。
- 复权处理:做量化分析必须用前复权或者后复权数据,否则遇到除权除息,股价会产生虚假的跳水,技术指标会完全失真。tushare里用
pro.adj_factor()接口获取复权因子,然后用收盘价乘以复权因子得到实际价格。 - 异常值过滤:某些极端行情下可能出现涨跌幅异常,虽然A股有涨跌停限制,但新股上市或者退市整理期会出现不正常的波动,最好按涨跌幅阈值做过滤。
清洗完之后,把数据保存成本地csv或者parquet文件,后续分析直接读文件,不要每次跑都重新请求接口,既慢又容易被限流。
数据预处理这段我多说一句:复权处理是新手最容易忽略但影响最大的一个环节。不夸张地说,如果你拿不复权的数据去计算均线、MACD这类价格衍生指标,遇到一次高送转,整个信号体系就全乱了。项目里务必处理好。
3. 股价与价值背离的多角度判断模型
3.1 什么叫价值,什么叫背离
在动手写代码之前,先想清楚一个核心问题:用什么指标代表"价值"?
价值这个概念本身就有很多维度。从基本面角度,可以用市盈率PE、市净率PB、股息率、盈利收益率(E/P)等;从技术面角度,可以用长期均线、布林带中轨等作为"市场公认的合理估值中枢";从量化角度,还可以用行业平均估值、历史估值分位数等做相对比较。
"背离"就是价格偏离这些价值参考线的程度。偏离得越远,修复的动力越强——当然,这只是理论上的说法,实际情况里"贵了还能更贵"、"便宜了还能更便宜"的情况也大量存在,所以背离信号只能作为参考,不能作为无脑反向操作的依据。
3.2 估值类背离指标:PE/PB分位数模型
最经典的估值背离指标是历史估值分位数。思路是:把某只股票过去5年(或者10年)每天的PE、PB数据取出来,算当前值处于历史区间的什么位置。如果当前PE处于历史的90%分位以上,说明估值偏贵;如果处于10%分位以下,说明估值偏便宜。
代码如下:
import tushare as ts import pandas as pd import numpy as np pro = ts.pro_api('你的token') def get_valuation_basic(ts_code, start_date='20190101', end_date='20241231'): """ 获取股票的历史每日估值指标 """ df = pro.daily_basic( ts_code=ts_code, start_date=start_date, end_date=end_date, fields='ts_code,trade_date,close,pe_ttm,pb,ps_ttm,div_ratio,total_mv' ) df['trade_date'] = pd.to_datetime(df['trade_date']) df = df.sort_values('trade_date').reset_index(drop=True) # 计算PE分位数(剔除异常值:PE为负数的亏损股需要单独处理) df['pe_ttm'] = df['pe_ttm'].replace([np.inf, -np.inf], np.nan) valid_pe = df.loc[df['pe_ttm'] > 0, 'pe_ttm'].dropna() df['pe_percentile'] = df['pe_ttm'].apply( lambda x: (valid_pe <= x).mean() if pd.notna(x) and x > 0 else np.nan ) # 同理计算PB分位数 df['pb'] = df['pb'].replace([np.inf, -np.inf], np.nan) valid_pb = df.loc[df['pb'] > 0, 'pb'].dropna() df['pb_percentile'] = df['pb'].apply( lambda x: (valid_pb <= x).mean() if pd.notna(x) and x > 0 else np.nan ) return df # 示例:贵州茅台 valuation_df = get_valuation_basic('600519.SH') print(valuation_df.tail())需要说明的是,这里的分位数计算用的是"过去全部有效观测值"的累积分布,不是滚动窗口。实际项目中建议用滚动5年窗口来计算分位数,这样更符合"当前估值和近5年比处于什么水平"的语境,避免因为公司基本面发生根本性变化(比如从成长股变成价值股)导致的历史区间失真。
3.3 价格与技术面的背离信号:均线偏离度和RSI极端值
基本面估值是一个维度,但A股市场很多时候炒的就是情绪和资金面,基本面和价格可以长期背离。所以我们还需要从技术面找信号。
我用得比较多的是均线偏离度。定义很简单:
偏离度 = (当前价格 - 长期均线) / 长期均线 × 100%
当偏离度超过正20%甚至30%时,说明短期涨幅过大,价格明显偏离内在中枢;当偏离度低于负20%时,说明超跌明显。代码如下:
# 读取清洗后的日线数据,假设df包含close列 df['ma20'] = df['close'].rolling(window=20).mean() df['ma60'] = df['close'].rolling(window=60).mean() df['ma120'] = df['close'].rolling(window=120).mean() # 计算价格相对于120日均线的偏离度 df['deviation_ma120'] = (df['close'] - df['ma120']) / df['ma120'] * 100 # 标记极端偏离状态 df['overbought'] = df['deviation_ma120'] > 20 df['oversold'] = df['deviation_ma120'] < -20RSI(相对强弱指标)可以辅助判断超买超卖。RSI超过70通常认为是超买,低于30是超卖。但A股市场经常出现RSI在70以上钝化的情况,尤其是强势股,可以连续几周RSI都挂在80以上不下来。所以RSI更适合做拐点确认,而不是单一买卖依据。
我的做法是:把估值分位数、均线偏离度、RSI三个维度结合起来,至少两个维度发出同向信号时,才标记一次"背离"事件。单一信号经常是假信号,多个维度共振时,可信度明显提高。
3.4 构建多维度背离评分体系
与其分别看三个指标,不如把它们整合成一个综合评分。这个评分就是"多角度解析"的落地点。
具体思路:
- PE分位数(20%权重):数值越高,估值越贵,偏离度越大。
- PB分位数(20%权重):同上。
- 均线偏离度(30%权重):数值越极端,短期情绪越亢奋或恐慌。
- RSI背离度(30%权重):RSI与价格走势是否形成顶背离或底背离。
综合评分的计算公式可以做成:
def compute_composite_score(df): """ 计算综合背离评分 分数范围理论上 -100 到 100 正分代表价格高于内在价值(偏贵),负分代表价格低于内在价值(偏便宜) """ # 对每个指标做归一化处理,这里以PE分位数为例,范围0~100 pe_score = df['pe_percentile'].fillna(50) * 100 # 均线偏离度映射到评分:偏离越大,分数越高 # 以±30%为极端阈值,做线性映射 dev_score = np.clip(df['deviation_ma120'] / 30, -1, 1) * 100 # RSI映射:50为中轴,70以上偏多,30以下偏空 rsi_score = (df['rsi14'] - 50) / 50 * 100 df['composite_score'] = ( pe_score * 0.2 + df['pb_percentile'].fillna(50) * 100 * 0.2 + dev_score * 0.3 + rsi_score * 0.3 ) return df这里的分值和权重都可以根据自己的经验调整,不是标准答案。重要的是形成了"多角度"的统一度量框架,后续做预测和回测都可以在这个score基础上进行。
4. 构建行情涨跌预测模型
4.1 预测的目标到底是什么
先说清楚"预测行情涨跌"的定义。我们不是要预测明天涨还是跌这样过于短期的随机事件,短线价格受太多不可控因素影响,预测准确率很难稳定超过50%多少。更合理的做法是预测未来一段时间(比如未来5天、10天、20天)的收益率区间或者涨跌方向概率。
所以我定义了两个预测目标:
- 方向预测:未来N天收益率为正还是为负。
- 幅度预测:未来N天的累计收益率具体大概在什么范围。
对于方向预测,用分类模型;对于幅度预测,用回归模型。两者可以共用同一套特征。
4.2 特征工程:从数据中挖出有效因子
特征工程决定了预测模型的上限。我整理了一下项目中用的特征,分成四类:
- 价格动量类:过去5天、10天、20天、60天的收益率,短中长期动量。
- 波动率类:过去20天的年化波动率、ATR(平均真实波幅)。
- 估值类:上面计算的PE分位数、PB分位数、综合背离评分。
- 成交类:成交量变化率、换手率、量比。量价配合关系在A股尤其重要。
一个比较实用的量价背离特征是:价格上涨但成交量萎缩。这种量价背离往往说明上涨动力不足,后续回调概率大。反过来,价格下跌但成交量放大,说明抛压沉重,短期可能还有下跌惯性。
特征代码示例:
# 假设df包含close, vol, amount等列 df['return_5'] = df['close'].pct_change(5) df['return_10'] = df['close'].pct_change(10) df['return_20'] = df['close'].pct_change(20) df['volatility_20'] = df['close'].pct_change().rolling(20).std() * np.sqrt(252) df['volume_change'] = df['vol'].pct_change(5) df['turnover_rate'] = df['amount'] / df['total_mv'] * 100 # 量价背离信号:价格上涨但成交量下降 df['price_up_volume_down'] = ((df['return_5'] > 0) & (df['volume_change'] < 0)).astype(int) # 未来10天收益作为预测标签 df['future_return_10'] = df['close'].shift(-10) / df['close'] - 1 df['future_positive_10'] = (df['future_return_10'] > 0).astype(int)4.3 模型选择:逻辑回归、随机森林还是XGBoost
预测模型的选择上,我试过不少,最终保留了三个:
- 逻辑回归:简单、可解释性强,适合做baseline。系数可以直接看出来哪些特征对涨跌影响最大。
- 随机森林:非线性关系捕捉能力比逻辑回归强,对异常值不敏感,不需要大量特征工程。
- XGBoost / LightGBM:实际表现最好,对结构化数据几乎是无脑最优解,但需要花时间调参,且容易过拟合。
我一般用逻辑回归做基线验证特征有效性,如果逻辑回归都跑不出有效的信号(测试集AUC低于0.52),说明特征和数据质量有问题,换更复杂的模型意义不大。先用简单模型验证思路,再用复杂模型提精度,这个顺序非常推荐。
代码示例(用随机森林):
from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, roc_auc_score # 选择特征列和目标列 features = [ 'return_5', 'return_10', 'return_20', 'volatility_20', 'volume_change', 'price_up_volume_down', 'pe_percentile', 'pb_percentile', 'composite_score', 'rsi14' ] X = df[features].dropna() y = df.loc[X.index, 'future_positive_10'] # 时序交叉验证,注意不要用普通K-Fold,会数据泄露 tscv = TimeSeriesSplit(n_splits=5) auc_scores = [] for train_idx, test_idx in tscv.split(X): X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] y_train, y_test = y.iloc[train_idx], y.iloc[test_idx] model = RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42) model.fit(X_train, y_train) y_pred_prob = model.predict_proba(X_test)[:, 1] auc = roc_auc_score(y_test, y_pred_prob) auc_scores.append(auc) print(f'Fold AUC: {auc:.4f}') print(f'Mean AUC: {np.mean(auc_scores):.4f}')这里有一个非常关键的坑:预测未来一定要用时间序列交叉验证,不能用普通的K-Fold。普通K-Fold随机打乱样本后,训练集里包含了未来数据,模型等于"偷看答案",回测指标虚高,实盘一用就现原形。TimeSeriesSplit保证训练集时间永远在测试集之前,这才是接近真实场景的评估方式。
4.4 预测结果的合理使用策略
即使模型训练出来AUC到了0.58甚至0.6,也绝对不意味着跟着模型买就能赚钱。这里面的差距主要在交易成本、滑点、市场冲击,以及模型预测的是方向概率而不是确定性事实。
我的实际用法是:把模型的预测概率当成一个仓位调节器,而不是简单的买卖信号。预测上涨概率超过0.55时,可以适当加仓;低于0.45时,降低仓位或者空仓等待;介于0.45到0.55之间,属于信号模糊区,不操作。这样避免了模型频繁发出信号导致交易成本吞噬收益的问题。
5. 回测框架与效果评估
5.1 从信号生成到收益曲线
有了模型,下一步就是回测。回测的框架不复杂,核心就是正确地模拟交易逻辑:满足买入条件时买入,满足卖出条件时卖出,记录每次交易的盈亏,最后汇总成净值曲线。
我自己比较习惯写简洁的回测代码,不依赖现成的backtrader之类的框架,因为策略逻辑一旦复杂起来,调试自己的代码更直接。不过如果你想快速验证,用backtrader、vectorbt、qlib都可以,没必要重复造轮子。
关键逻辑:
def backtest(df, buy_threshold=0.55, sell_threshold=0.45): """ 简单回测:预测概率高于buy_threshold买入,低于sell_threshold卖出 只做多头,不做空 """ df = df.copy() df['signal'] = 0 df.loc[df['pred_prob'] > buy_threshold, 'signal'] = 1 df.loc[df['pred_prob'] < sell_threshold, 'signal'] = -1 # 交易信号:signal从0变成1代表买入,从1变成0/-1代表卖出 # 这里用持币/持股两个状态简化处理 position = 0 entry_price = 0 trades = [] for i in range(len(df)): if position == 0 and df['signal'].iloc[i] == 1: # 买入 position = 1 entry_price = df['close'].iloc[i] entry_date = df['trade_date'].iloc[i] elif position == 1 and df['signal'].iloc[i] != 1: # 卖出 exit_price = df['close'].iloc[i] exit_date = df['trade_date'].iloc[i] return_pct = (exit_price - entry_price) / entry_price trades.append({ 'entry_date': entry_date, 'exit_date': exit_date, 'entry_price': entry_price, 'exit_price': exit_price, 'return_pct': return_pct }) position = 0 if position == 1: # 回测结束时强制平仓 exit_price = df['close'].iloc[-1] return_pct = (exit_price - entry_price) / entry_price trades.append({ 'entry_date': entry_date, 'exit_date': df['trade_date'].iloc[-1], 'entry_price': entry_price, 'exit_price': exit_price, 'return_pct': return_pct }) trade_df = pd.DataFrame(trades) # 计算累计收益 cumulative_return = (trade_df['return_pct'] + 1).prod() - 1 # 计算胜率 win_rate = (trade_df['return_pct'] > 0).mean() return trade_df, cumulative_return, win_rate这个回测逻辑做了极大简化,没有考虑手续费和滑点,所以出来的收益会比实际操作偏高。我在项目中会另外扣除双边千分之一点五左右的手续费和千分之二的滑点,更贴近真实情况。
5.2 回测指标怎么看:不要只看收益率
很多人做回测只盯着年化收益率,这是大忌。一个年化50%的策略,如果最大回撤60%,普通人根本拿不住,中途就割肉离场了。我比较看重的指标有这么几个:
| 指标 | 含义 | 我的经验参考值 |
|---|---|---|
| 累计收益率 | 整个回测周期的总收益 | 越高越好,但需结合回撤看 |
| 年化收益率 | 折算成年度的收益率 | 比纯累计收益更可比 |
| 最大回撤 | 净值从高点到低点的最大跌幅 | 控制在20%以内较舒服 |
| 胜率 | 盈利交易占比 | 40%以上即可接受 |
| 盈亏比 | 平均盈利/平均亏损 | 最好大于1.5 |
| 夏普比率 | 单位风险所获得的超额收益 | 大于1算及格,大于2比较优秀 |
计算最大回撤的代码很简单:
# 假设equity_curve是每日净值序列 drawdown = equity_curve / equity_curve.cummax() - 1 max_drawdown = drawdown.min() print(f'最大回撤: {max_drawdown:.2%}')5.3 过拟合与参数稳健性检查
量化交易最大的敌人是过拟合——模型在历史数据上表现完美,一到实盘就亏钱。我在实际项目中遇到过太多次这种"看起来很美"的策略,总结下来有几个防过拟合的手段:
- 参数不要过多:逻辑回归、简单的阈值策略参数少,相对不容易过拟合。XGBoost如果调参调得特别细,很容易钻数据牛角尖。
- 跨样本验证:训练集用了2019-2022年的数据,测试集就用2023-2024年的数据,中间留出一段gap不要接触。如果跨样本表现明显变差,说明策略不稳定。
- 参数敏感度分析:比如把买入阈值从0.55换成0.5、0.6,观察收益变化是否剧烈。如果参数稍微一动,结果就从盈利变成亏损,这个策略基本不可用。
- 换股票池测试:在某一只股票上表现好的策略,换到另外几只不同行业的股票上,如果普遍有效,说明策略逻辑有普适性;如果只在特定股票上有效,很可能就是过拟合了个股特性。
我个人的要求是:策略至少要在3只不同行业、不同市值风格的股票上都能跑出正收益,且回撤可控,才会考虑进一步投入精力做实盘验证。单只股票上的回测再漂亮,我都持保留态度。
6. 常见问题与项目经验总结
6.1 数据获取失败与接口限流问题
实际跑数据的时候最常见的问题就是接口请求报错。tushare有积分限制,积分不够,某些接口调用频率会被限制,比如每分钟只能调用几次。我的处理方式是写一个简单的重试机制和本地缓存:
import time def fetch_with_retry(func, *args, max_retries=3, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt == max_retries - 1: raise e time.sleep(2 ** attempt) return None另外,本地缓存非常关键。第一次拉取数据后保存成parquet文件,之后每次都先检查本地文件是否存在、数据是否覆盖到最新日期,只增量拉取新的数据。这样既避免被限流,又提升效率。
6.2 预测模型AUC偏低是不是说明方法没用
很多新手跑出AUC在0.52-0.56之间,就觉得模型废了,其实不是。金融预测本来信噪比就极低,能稳定跑出0.55以上的AUC已经具备参考价值,关键是看你怎么用这个信号。
我见过一个实盘做得还不错的朋友,他的模型AUC也就0.57左右,但他把预测概率和仓位管理、止损规则结合起来,把预期收益率不高但胜率相对高的交易挑出来做,长期下来曲线很稳。模型不是用来预测每一次涨跌的,是用来筛选你更有把握的时刻。
相比之下,那种声称自己模型AUC 0.8以上能稳定跑赢市场的人,要么是用了未来数据,要么是严重过拟合,要么根本没有真实交易过。这个领域,对"神技"保持警惕是必要的。
6.3 实盘和回测的差距到底在哪里
最后说一个老生常谈但依然很多人栽跟头的点:回测和实盘的差距。我总结下来主要有三个来源:
一是交易成本。回测里如果不扣手续费和滑点,实际收益会大打折扣。尤其做中短线,频繁交易下,光手续费就能吃掉大部分利润。
二是信号延迟。回测中假设你能在信号出现的当天收盘价买入,但实际操作中,信号出现后你看到、确认、下单,往往已经晚了半天甚至一天。尤其在开盘跳空的情况下,买入价和信号价可以差很 多。
三是心理因素。回测里最大回撤只是数字,实盘中连续亏损十几笔,大多数人都会开始怀疑模型、手动干预、甚至放弃策略。这一点很难用代码解决,只能靠仓位管理控制回撤在心理承受范围内,以及提前想好交易纪律。
6.4 项目还可以怎么扩展
这个项目做完之后,我个人觉得有几个值得继续深入的方向:
- 引入行业对比,把个股估值分位数变成"个股vs行业"的相对估值,能过滤掉行业整体的系统性高估或低估。
- 尝试多因子打分体系,把估值、动量、波动率、资金流向等维度综合成选股因子,从单票预测扩展到股票池排名筛选。
- 做实盘模拟盘验证,用模拟账户跑一个月,记录实盘和回测的偏差,再回头调整参数和模型。这一步是提升策略可信度的必经之路。
根据我个人经验,做完这个项目,你对"股价为什么会涨、为什么会跌"的理解会比只看K线的人深入很多。量化的意义不在于找到圣杯,而在于用数据把自己关于市场的模糊直觉,变成一个可以检验和改进的系统。哪怕最终收益一般,这个过程本身就已经值回票价了。