Python实战:从零构建茅台股票分析系统,解锁量化投资入门新姿势

最近几年,身边对投资理财感兴趣的朋友越来越多,尤其是对A股市场的明星股——贵州茅台(600519)的关注度一直居高不下。很多人好奇,除了看K线图、听消息,有没有更“技术流”的方式来理解一只股票?答案是肯定的。今天,我们就抛开那些复杂的金融术语,用程序员最熟悉的武器——Python,来亲手搭建一个茅台股票历史数据分析系统。这不仅仅是一次代码练习,更是你理解市场波动、培养数据思维的第一步。无论你是想验证某个投资策略,还是单纯对数据分析感兴趣,这篇文章都将带你走完从数据获取、清洗、分析到可视化的完整闭环。

1. 环境搭建与核心工具栈选择

工欲善其事,必先利其器。在开始我们的数据分析之旅前,确保你有一个顺手的开发环境至关重要。对于金融数据分析,我强烈推荐使用 Anaconda 来管理Python环境和包依赖,它能极大避免版本冲突和依赖地狱。如果你还没安装,去官网下载对应操作系统的安装包,一路下一步即可。

接下来,我们来聊聊这次要用到的几个核心库。它们就像我们工具箱里的不同工具,各司其职:

  • Tushare: 这是我们的“数据采集员”。它是一个免费、开源的财经数据接口库,可以非常方便地获取国内股票、基金、期货等历史行情数据。对于个人学习和研究来说,它的免费数据额度完全够用。
  • Pandas: 当之无愧的“数据分析引擎”。几乎所有基于表格的数据操作,比如筛选、分组、聚合、时间序列处理,Pandas都能优雅高效地完成。它是Python数据分析的基石。
  • NumPy: 提供高性能的数值计算能力,是Pandas的底层依赖之一。在处理大规模数组运算时,它不可或缺。
  • Matplotlib: 我们的“视觉设计师”。数据分析的结果最终需要呈现,无论是简单的折线图、柱状图,还是复杂的K线图,Matplotlib都能胜任。为了更美观,你也可以后续探索 SeabornPlotly

安装这些库非常简单。打开你的终端(Windows用户用Anaconda Prompt或系统CMD,Mac/Linux用户用Terminal),依次执行以下命令:

pip install tushare
pip install pandas numpy matplotlib

提示:如果网络环境导致pip install较慢或失败,可以尝试使用国内镜像源,例如加上 -i https://pypi.tuna.tsinghua.edu.cn/simple 参数。

安装完成后,建议你打开Jupyter Notebook或你喜欢的IDE(如VS Code、PyCharm),新建一个Python文件,跟着下面的步骤一步步操作。亲手敲代码和直接复制粘贴的感受是完全不同的,过程中遇到的每一个报错都是你进步的阶梯。

2. 数据获取与初步探索:把茅台请进你的电脑

一切准备就绪,现在让我们把茅台股票的历史数据“下载”到本地。使用Tushare获取数据非常简单,你只需要知道股票代码和起止日期。

import tushare as ts
import pandas as pd

# 设置股票代码和起始日期
stock_code = '600519'  # 贵州茅台
start_date = '2000-01-01'  # 尽可能获取更早的数据

# 使用Tushare的pro接口(需注册获取token,基础数据免费)
# 这里我们先使用通用接口,简单演示
df = ts.get_k_data(code=stock_code, start=start_date)
print(f"数据形状: {df.shape}")
print(df.head())

运行这段代码,你会看到一个DataFrame,它包含了从指定起始日期到最近一个交易日的每日行情数据,通常包括以下列:

  • date: 交易日期
  • open: 开盘价
  • close: 收盘价
  • high: 最高价
  • low: 最低价
  • volume: 成交量

获取到数据后,一个好的习惯是立即将其保存到本地,避免每次分析都重复调用接口,也能在断网时继续工作。

# 将数据保存到CSV文件
file_path = './maotai_historical_data.csv'
df.to_csv(file_path, index=False)  # index=False避免将行索引也存入文件
print(f"数据已保存至: {file_path}")

# 从本地CSV文件读取数据
df = pd.read_csv(file_path)

现在,数据已经在你手上了。但在深入分析前,我们需要对其进行一些“体检”和“整理”。

# 查看数据的基本信息
print(df.info())

# 查看数据的统计摘要
print(df.describe())

# 检查是否有缺失值
print(f"缺失值统计:\n{df.isnull().sum()}")

通过df.info(),你可以看到每列的数据类型。这里有个关键点:date列默认是字符串(object)类型,为了后续基于时间的分析和索引,我们必须将其转换为Pandas的datetime类型。

# 将`date`列转换为日期时间类型,并设置为数据框的索引
df['date'] = pd.to_datetime(df['date'])
df.set_index('date', inplace=True)  # inplace=True表示直接修改原DataFrame

# 再次查看前几行,确认索引已更改
print(df.head())

将日期设为索引后,我们可以利用Pandas强大的时间序列索引功能进行灵活的数据切片,例如df['2020-01':'2020-06']就能轻松获取2020年上半年的所有数据。

3. 核心分析:从数据中挖掘市场信号

拥有了干净、结构化的数据,我们就可以开始提出具体问题,并用代码寻找答案了。这才是数据分析最有魅力的部分。

3.1 识别大幅波动日:上涨与跳空

投资者常常关注股价的单日大幅波动。让我们先找出所有**收盘价比开盘价上涨超过3%**的交易日。这种日子通常意味着盘中买盘力量强劲。

# 计算每日涨跌幅(基于开盘价)
df['daily_change_pct'] = (df['close'] - df['open']) / df['open'] * 100

# 找出涨幅超过3%的日期
big_up_days = df[df['daily_change_pct'] > 3.0]
print(f"收盘比开盘上涨超过3%的交易日共有 {len(big_up_days)} 天")
print("部分日期如下:")
print(big_up_days.index[:10].strftime('%Y-%m-%d').tolist())  # 格式化输出前10个日期

接下来,我们寻找另一种情况:**开盘价相比前一日收盘价跌幅超过2%**的交易日。这通常被称为“向下跳空缺口”,可能由隔夜利空消息导致。

# 计算开盘价相对于前一日收盘价的涨跌幅
df['gap_down_pct'] = (df['open'] - df['close'].shift(1)) / df['close'].shift(1) * 100

# 找出跌幅超过2%的日期
gap_down_days = df[df['gap_down_pct'] < -2.0]
print(f"\n开盘比前日收盘跌幅超过2%的交易日共有 {len(gap_down_days)} 天")
print("部分日期如下:")
print(gap_down_days.index[:10].strftime('%Y-%m-%d').tolist())

注意:这里使用了.shift(1)方法,它的作用是将close列的所有值向下移动一行。这样,每一行的close.shift(1)就代表了前一交易日的收盘价,从而实现了与当日开盘价的对比。

为了更直观,我们可以将这两种剧烈波动的日期在股价走势图上标记出来。但在此之前,让我们先思考一个更复杂的策略性问题。

3.2 模拟一个长期定投策略:收益如何?

假设我们从2010年1月1日开始,执行一个简单的机械策略:在每个月的第一个交易日买入1手(100股),然后在每年最后一个交易日卖出当年积累的所有股票。这个策略模拟了一种“定期定额投资+年度止盈”的思路。到2023年底,我们的收益会怎样?

我们需要拆分一下任务:

  1. 划定时间范围(2010-01-01 至 2023-12-31)。
  2. 找出每个月第一个交易日(买入日)和每年最后一个交易日(卖出日)。
  3. 计算买入总成本、卖出总收入和剩余股票价值。
# 1. 划定分析时间段
analysis_start = '2010-01-01'
analysis_end = '2023-12-31'
df_period = df.loc[analysis_start:analysis_end].copy()

# 2. 使用重采样方法快速找到每月首日和每年末日
# 买入日:每月第一个交易日
buy_dates = df_period.resample('BM').first().dropna().index  # 'BM'是Business Month End,取每月末,再取first得到下月初?这里需要调整。
# 更准确的方法是找到每月有数据的第一个日期
monthly_first = df_period.groupby(pd.Grouper(freq='M')).first().dropna()
buy_dates = monthly_first.index

# 卖出日:每年最后一个交易日
yearly_last = df_period.groupby(pd.Grouper(freq='Y')).last().dropna()
# 注意:最后一年的股票可能还未卖出,我们假设策略结束时(2023年底)强制卖出
sell_dates = yearly_last.index

print(f"买入次数: {len(buy_dates)}")
print(f"卖出次数: {len(sell_dates)}")

# 3. 计算成本与收入
# 买入总成本:每月买入100股 * 当月第一个交易日的开盘价
total_cost = (monthly_first['open'] * 100).sum()

# 卖出总收入:每年卖出当年积累的股票(每年买入12手,共1200股)* 当年最后一个交易日的开盘价
# 注意:第一年(2010年)买入的股票在2010年底卖出,以此类推。
# 我们计算每年卖出时的持股数量(每年固定1200股,除了最后一年)
shares_per_year = 1200
# 最后一年(2023年)的卖出日,我们卖出之前所有年份积累的股票(但这里策略是每年卖光,所以每年末持股为0,这个计算逻辑需要修正)
# 实际上,这个策略是“每年末卖出所有股票”,所以每年卖出时,卖出的就是当年买入的12手。
# 但我们的计算简化了:总卖出收入 = 每年卖出1200股 * 当年卖出价。
# 然而,2023年末的卖出,其股票是2023年当年每月买入的,到12月底才卖出。所以卖出价用2023年最后一个交易日的开盘价。
# 让我们重新梳理一个更清晰的逻辑:

上面的代码逻辑有点绕,因为“每月买、每年卖”会导致跨年持股计算复杂。我们换一种更清晰、逐日模拟的思路来重写这个策略:

# 初始化变量
initial_capital = 0  # 初始资金为0,我们只计算现金流
cash = 0
shares_held = 0
buy_price = []
sell_price = []

# 遍历2010-01-01至2023-12-31的每一个交易日
for date in df_period.index:
    # 判断是否为每月第一个交易日(简化:判断日期是否为当月1号,且该日有交易)
    if date in buy_dates:
        # 买入1手(100股)
        price = df_period.loc[date, 'open']
        cash -= price * 100  # 现金减少
        shares_held += 100   # 持股增加
        buy_price.append(price)
        # print(f"{date.date()}: 买入100股 @ {price:.2f}元")

    # 判断是否为每年最后一个交易日
    if date in sell_dates:
        if shares_held > 0:
            price = df_period.loc[date, 'open']
            cash += price * shares_held  # 现金增加
            sell_price.append(price)
            # print(f"{date.date()}: 卖出{shares_held}股 @ {price:.2f}元")
            shares_held = 0  # 清空持股

# 策略结束日(2023-12-31)处理:如果还有持股,按当日收盘价卖出
final_date = df_period.index[-1]
if shares_held > 0:
    final_price = df_period.loc[final_date, 'close']
    cash += final_price * shares_held
    # print(f"{final_date.date()}: 策略结束,卖出剩余{shares_held}股 @ {final_price:.2f}元")

# 计算总收益(最终现金 - 总买入成本 + 最终持股价值?实际上现金已包含最终卖出)
# 因为初始资金为0,所以最终现金就是总收益(若为负则是亏损)
total_profit = cash
print(f"\n策略模拟结果(2010-2023):")
print(f"总买入成本(负值): {sum([p*100 for p in buy_price]):.2f} 元")
print(f"总卖出收入: {sum([p*s for p, s in zip(sell_price, [1200]*len(sell_price[:-1]))]) + (sell_price[-1]*shares_held if shares_held>0 else 0):.2f} 元") # 简化估算
print(f"最终现金(即总收益): {total_profit:.2f} 元")

这个计算过程揭示了策略回测的复杂性,需要考虑交易时间点、持股数量变化等细节。在实际量化分析中,我们会使用更专业的回测框架(如backtrader, zipline),但通过这个手写模拟,你能深刻理解每个计算步骤背后的金融含义。

4. 技术指标实战:双均线策略的构建与回测

移动平均线(MA)是技术分析中最常用的工具之一。它通过计算过去一段时间内收盘价的平均值,来平滑价格波动,显示趋势方向。短期均线(如5日线)对价格反应灵敏,长期均线(如30日线)则更稳定,代表长期趋势。两者的交叉点常被用作买卖信号。

4.1 计算并可视化双均线

让我们为茅台股价计算5日移动平均线(MA5)和30日移动平均线(MA30)。

import matplotlib.pyplot as plt
# 设置中文字体(如果图表需要显示中文)
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']  # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False  # 用来正常显示负号

# 计算移动平均线
df['MA5'] = df['close'].rolling(window=5, min_periods=1).mean()  # min_periods=1允许最初几天也有值
df['MA30'] = df['close'].rolling(window=30, min_periods=1).mean()

# 选取最近几年的数据来绘图,避免数据太多看不清楚
plot_df = df['2020-01-01':'2023-12-31']

# 绘制股价和均线
plt.figure(figsize=(14, 7))
plt.plot(plot_df.index, plot_df['close'], label='收盘价', linewidth=1, alpha=0.7)
plt.plot(plot_df.index, plot_df['MA5'], label='5日均线 (MA5)', linewidth=2)
plt.plot(plot_df.index, plot_df['MA30'], label='30日均线 (MA30)', linewidth=2)

plt.title('贵州茅台 (600519) 股价与双均线走势 (2020-2023)')
plt.xlabel('日期')
plt.ylabel('价格 (元)')
plt.legend()
plt.grid(True, linestyle='--', alpha=0.5)
plt.tight_layout()
plt.show()

运行代码后,你会看到一张图表,清晰地展示了股价与两条均线的互动。当短期均线(MA5)从下往上穿过长期均线(MA30)时,形成“黄金交叉”(金叉),常被视为买入信号;反之,当MA5从上往下穿过MA30时,形成“死亡交叉”(死叉),常被视为卖出信号。

4.2 自动识别金叉与死叉

我们可以用代码自动找出历史上所有的金叉和死叉日期。

# 为了准确比较,需要确保MA5和MA30在计算初期没有NaN值,这里我们截取有效数据
valid_df = df.dropna(subset=['MA5', 'MA30']).copy()

# 生成信号:1代表金叉(MA5上穿MA30), -1代表死叉(MA5下穿MA30), 0代表无信号
valid_df['Signal'] = 0  # 初始化信号列

# 判断上穿:当日MA5 > MA30,且前一日MA5 <= MA30
golden_cross = (valid_df['MA5'] > valid_df['MA30']) & (valid_df['MA5'].shift(1) <= valid_df['MA30'].shift(1))
valid_df.loc[golden_cross, 'Signal'] = 1

# 判断下穿:当日MA5 < MA30,且前一日MA5 >= MA30
death_cross = (valid_df['MA5'] < valid_df['MA30']) & (valid_df['MA5'].shift(1) >= valid_df['MA30'].shift(1))
valid_df.loc[death_cross, 'Signal'] = -1

# 提取金叉和死叉的日期
golden_dates = valid_df[valid_df['Signal'] == 1].index
death_dates = valid_df[valid_df['Signal'] == -1].index

print(f"识别出金叉次数: {len(golden_dates)}")
print(f"识别出死叉次数: {len(death_dates)}")
print("\n最近5次金叉日期:")
print(golden_dates[-5:].strftime('%Y-%m-%d').tolist())
print("\n最近5次死叉日期:")
print(death_dates[-5:].strftime('%Y-%m-%d').tolist())

4.3 回测双均线策略

现在,让我们模拟一个简单的策略:初始资金10万元,出现金叉时全仓买入,出现死叉时全仓卖出。不考虑交易费用和税费,看看这个策略的历史表现如何。

# 设定回测时间段和初始资金
backtest_start = '2010-01-01'
backtest_end = '2023-12-31'
bt_df = valid_df.loc[backtest_start:backtest_end].copy()

initial_capital = 100000.0
capital = initial_capital
position = 0  # 持股数量
trade_log = []  # 记录交易

# 遍历回测期内的每一天
for i in range(1, len(bt_df)):  # 从第二天开始,因为需要前一天的数据判断交叉
    current_date = bt_df.index[i]
    prev_signal = bt_df.iloc[i-1]['Signal']
    current_signal = bt_df.iloc[i]['Signal']
    current_price = bt_df.iloc[i]['open']  # 假设以开盘价交易

    # 死叉卖出信号(前一天是金叉或持有,当天是死叉)
    if prev_signal == 1 and current_signal == -1 and position > 0:
        # 全仓卖出
        capital += position * current_price
        trade_log.append((current_date, 'SELL', current_price, position))
        position = 0

    # 金叉买入信号(前一天是死叉或空仓,当天是金叉)
    elif prev_signal == -1 and current_signal == 1 and capital > 0:
        # 全仓买入,计算能买多少手(100股/手)
        max_shares = int(capital // (current_price * 100)) * 100
        if max_shares >= 100:  # 至少买1手
            position = max_shares
            capital -= position * current_price
            trade_log.append((current_date, 'BUY', current_price, position))

# 回测结束日,如果还持有股票,按当日收盘价卖出
final_price = bt_df.iloc[-1]['close']
if position > 0:
    capital += position * final_price
    trade_log.append((bt_df.index[-1], 'SELL (Final)', final_price, position))

# 计算最终收益
final_total = capital
total_return = (final_total - initial_capital) / initial_capital * 100

print("=== 双均线策略回测结果(2010-2023)===")
print(f"初始资金: {initial_capital:.2f} 元")
print(f"最终总资产: {final_total:.2f} 元")
print(f"总收益: {final_total - initial_capital:.2f} 元")
print(f"总收益率: {total_return:.2f}%")
print(f"交易次数: {len(trade_log)}")

这个简单的回测能让你对策略的潜在表现有个大致了解。但请务必记住,这只是一个高度简化的模型,实际投资中必须考虑交易成本(佣金、印花税)、滑点(实际成交价与预期价的偏差)、以及更复杂的市场环境

5. 数据可视化进阶:让洞察一目了然

数字和表格是冰冷的,图表却能讲故事。我们已经画了均线图,现在再创建几个更有洞察力的可视化。

5.1 股价走势与交易信号叠加图

将我们之前识别的金叉/死叉信号标记在股价图上,能直观看到策略的买卖点。

plt.figure(figsize=(16, 8))

# 绘制收盘价
plt.plot(bt_df.index, bt_df['close'], label='收盘价', color='black', linewidth=1, alpha=0.7)

# 标记金叉(买入信号)和死叉(卖出信号)
# 找出信号发生日的股价,用于标记位置
buy_points = bt_df[bt_df['Signal'].shift(1) == -1]  # 前一天是死叉,当天是金叉?这里用信号日开盘价近似
sell_points = bt_df[bt_df['Signal'].shift(1) == 1]   # 前一天是金叉,当天是死叉?

# 更精确地,我们用交易日志里的日期和价格
buy_dates_from_log = [log[0] for log in trade_log if log[1].startswith('BUY')]
buy_prices_from_log = [log[2] for log in trade_log if log[1].startswith('BUY')]
sell_dates_from_log = [log[0] for log in trade_log if log[1].startswith('SELL') and 'Final' not in log[1]]
sell_prices_from_log = [log[2] for log in trade_log if log[1].startswith('SELL') and 'Final' not in log[1]]

plt.scatter(buy_dates_from_log, buy_prices_from_log, color='red', marker='^', s=100, label='买入信号 (金叉)', zorder=5)
plt.scatter(sell_dates_from_log, sell_prices_from_log, color='green', marker='v', s=100, label='卖出信号 (死叉)', zorder=5)

plt.title('贵州茅台股价与双均线策略买卖点 (2010-2023)')
plt.xlabel('日期')
plt.ylabel('价格 (元)')
plt.legend()
plt.grid(True, linestyle='--', alpha=0.5)
plt.tight_layout()
plt.show()

5.2 月度收益率分布直方图

分析股价月度涨跌的分布情况,可以帮助我们了解其波动特征。

# 计算月度收益率:每月最后一个交易日的收盘价相对于上月最后一个交易日收盘价的变化
monthly_prices = df['close'].resample('M').last()  # 获取每月末收盘价
monthly_returns = monthly_prices.pct_change() * 100  # 计算月度百分比收益率

# 绘制直方图
plt.figure(figsize=(10, 6))
plt.hist(monthly_returns.dropna(), bins=30, edgecolor='black', alpha=0.7)
plt.axvline(x=monthly_returns.mean(), color='red', linestyle='--', linewidth=2, label=f'均值: {monthly_returns.mean():.2f}%')
plt.axvline(x=monthly_returns.median(), color='green', linestyle='--', linewidth=2, label=f'中位数: {monthly_returns.median():.2f}%')
plt.title('贵州茅台月度收益率分布直方图')
plt.xlabel('月度收益率 (%)')
plt.ylabel('频率')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# 输出一些统计信息
print("月度收益率统计摘要:")
print(monthly_returns.describe())

5.3 相关性热力图(扩展)

如果你想探索更多维度,比如成交量与价格变动的关系,可以计算相关系数矩阵并用热力图展示。这需要引入seaborn库。

# 可选:如果需要画热力图,先安装seaborn: pip install seaborn
try:
    import seaborn as sns
    # 选取几个关键指标计算相关性
    correlation_df = df[['open', 'high', 'low', 'close', 'volume']].copy()
    # 计算价格变化和成交量变化,可能更有意义
    correlation_df['price_change'] = correlation_df['close'].pct_change()
    correlation_df['volume_change'] = correlation_df['volume'].pct_change()
    correlation_df = correlation_df.dropna()

    corr_matrix = correlation_df.corr()

    plt.figure(figsize=(10, 8))
    sns.heatmap(corr_matrix, annot=True, cmap='coolwarm', center=0, square=True, fmt='.2f')
    plt.title('股价与成交量指标相关性热力图')
    plt.tight_layout()
    plt.show()
except ImportError:
    print("Seaborn库未安装,跳过热力图绘制。如需使用,请运行 'pip install seaborn' 进行安装。")

走到这里,你已经完成了一个从数据获取到策略回测的完整股票分析项目。这个过程里最宝贵的不是最终那几个收益率数字,而是你亲手处理数据、定义规则、验证想法的完整逻辑链条。金融市场的复杂性远超任何一个模型,双均线策略在历史数据上可能表现不错,但绝不意味着在未来能持续有效。真正的价值在于,你掌握了用代码将投资想法具象化、定量化的能力。下次当你再听到一个“投资秘诀”时,不妨试着像今天一样,用数据去回测一下,看看它到底是在讲故事,还是真有统计上的优势。数据分析不会直接给你财富密码,但它能帮你擦亮眼睛,在信息的海洋里做一个更清醒的决策者。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐