Python在金融中的应用 · 第二部分

第五节:NumPy与模拟股票数据

本节从一份可以直接下载、读取和复现的模拟股票 CSV 开始。先把表格中的价格变成 NumPy 数组,再用向量化计算收益率、均值、波动率和组合结果,理解“数组是怎样帮助金融计算批量完成的”。

先认识这份案例数据

数据文件 stock_demo.csv 含有 4 只模拟股票、15 个日期和 60 条观测。数值是为了教学而合成的,不代表真实市场报价;它的优点是可以放心修改,且每次运行得到相同结果。

读取CSV 文件
转换价格数组
计算收益与风险
解释金融含义
字段示例含义
date2024-01-02观测日期。
ticker000001.SZ证券代码,用于区分资产。
open/high/low/close12.81 / 12.89 / 12.72 / 12.79开盘、最高、最低、收盘价;本节主要使用收盘价。
volume2000000成交股数,帮助观察交易活跃度。
turnover_million25.57成交额,单位为百万元。
market_cap_billion250.00市值,单位为十亿元。

建议先把 CSV 下载到课程项目的 data/ 文件夹,再在 Notebook 中使用相对路径。这样换一台电脑时,项目结构仍然清楚。

1. NumPy 到底解决什么问题

Python 列表可以保存数字,但金融计算常常需要同时处理几百只股票、几千个日期和多个情景。NumPy 的 ndarray 是专门存储同类数字的数组,支持整列运算:写一次公式,就能对每一个元素执行。

逐个写循环

需要先取出第一个价格,再取第二个价格,逐项计算。代码长,容易漏掉某一行。

数组运算

把一列价格放入数组后,prices[1:] / prices[:-1] - 1 会一次性得到所有日收益率。

import numpy as np

prices = np.array([10.0, 10.5, 10.2, 10.8])
returns = prices[1:] / prices[:-1] - 1
print(returns)
print("平均日收益率:", returns.mean())
print("日波动率:", returns.std(ddof=1))
[ 0.05 -0.02857143 0.05882353] 平均日收益率: 0.026750700280133335 日波动率: 0.048113066035324205

这里的 prices[1:] 是从第二个价格开始,prices[:-1] 是到倒数第二个价格为止;两者对齐后,每个分子都对应下一个交易日,每个分母都对应前一个交易日。

2. 读取 CSV 并检查数组形状

第五节先用一个纯 NumPy 的小练习理解数组,再用 Pandas 读取 CSV。现实项目中通常由 Pandas 负责读取表格,由 NumPy 负责数值运算;这不是竞争关系,而是分工。

from pathlib import Path
import pandas as pd
import numpy as np

file_path = Path("data/stock_demo.csv")
df = pd.read_csv(file_path)
close_prices = df["close"].to_numpy()
print("记录数:", close_prices.size)
print("数组形状:", close_prices.shape)
print("前 5 个收盘价:", close_prices[:5])
记录数: 60 数组形状: (60,) 前 5 个收盘价: [ 12.79 1683.25 180.71 42.19 12.81]

此时数组是 60 行的一维数组,股票之间和日期之间混在一起。要计算每只股票自己的收益率,需要先把长表整理成“日期 × 股票”的矩阵。这一步会在第六节详细讲解;现在先用已知的 15×4 数组体验 NumPy。

close_matrix = df.pivot(index="date", columns="ticker", values="close").to_numpy()
print(close_matrix.shape)
print(close_matrix[0])
(15, 4) [ 12.79 1683.25 180.71 42.19]

3. 用向量化计算收益率

最常用的简单收益率公式是:

r_t = P_t / P_(t-1) - 1。它表示价格从前一个观测日到当前观测日的相对变化,不是百分数;如果输出 0.01,阅读时要转成 1%。

returns_matrix = close_matrix[1:] / close_matrix[:-1] - 1
print("收益率矩阵形状:", returns_matrix.shape)
print("第一只股票前 3 个收益率:", returns_matrix[:3, 0])
print("第一天各股票收益率:", returns_matrix[0])
收益率矩阵形状: (14, 4) 第一只股票前 3 个收益率: [ 0.00156372 0.0039032 -0.00155521] 第一天各股票收益率: [ 0.00156372 0.00498036 0.00426556 0.00402939]

为什么从 15 个价格变成 14 个收益率?因为第一天没有“前一天价格”,无法计算第一天的变化。这种少一行不是错误,而是金融时间序列中的自然结果。

均值

returns_matrix.mean(axis=0) 按列计算每只股票平均日收益率。

波动率

std(axis=0, ddof=1) 描述日收益率的离散程度。

相关系数

np.corrcoef(returns_matrix.T) 比较股票收益同步程度。

4. 创建、查看和改变 NumPy 数组

NumPy 数组有维度、形状、数据类型和轴。一维可以表示一只股票的时间序列,二维可以表示日期 × 股票,三维还可以表示多个情景 × 日期 × 股票。

ndim数组有几个维度。
shape每个维度有多少元素,例如 15×4。
dtype元素的数据类型,例如 float64。
import numpy as np

one_dim = np.array([12.8, 1683.2, 180.7, 42.2])
two_dim = np.array([[12.8, 1683.2], [12.9, 1690.4]])
zeros = np.zeros((2, 3))
sequence = np.arange(0, 10, 2)
points = np.linspace(0, 1, 5)

for name, array in [("one_dim", one_dim), ("two_dim", two_dim),
                    ("zeros", zeros), ("sequence", sequence),
                    ("points", points)]:
    print(name, "ndim=", array.ndim, "shape=", array.shape,
          "dtype=", array.dtype)
print("sequence:", sequence)
print("points:", points)
one_dim ndim= 1 shape= (4,) dtype=float64 two_dim ndim= 2 shape= (2, 2) dtype=float64 zeros ndim= 2 shape= (2, 3) dtype=float64 sequence ndim= 1 shape= (5,) dtype=int64 points ndim= 1 shape= (5,) dtype=float64 sequence: [0 2 4 6 8] points: [0. 0.25 0.5 0.75 1. ]

zeros 常用于初始化结果矩阵,arange 按固定步长生成序列,linspace 按数量均匀分割区间。需要独立副本时使用 .copy(),避免修改视图时意外改变原始价格。

prices = np.array([[12.79, 12.81, 12.86],
                   [1683.25, 1690.43, 1690.49]])
print("第一行:", prices[0])
print("第二列:", prices[:, 1])
prices_copy = prices.copy()
prices_copy[0, 0] = 99
print("原数组左上角:", prices[0, 0])
print("复制数组左上角:", prices_copy[0, 0])
第一行: [ 12.79 12.81 12.86] 第二列: [ 12.81 1690.43] 原数组左上角: 12.79 复制数组左上角: 99.0

5. 索引、切片与布尔筛选

索引从 0 开始,切片右端不包含,布尔数组可以把满足条件的观测一次性筛出来。多个条件用 & 表示“并且”、用 | 表示“或者”,每个条件都要加括号。

close = np.array([12.79, 12.81, 12.86, 12.84, 12.75])
print("前三个:", close[:3])
print("最后两个:", close[-2:])
print("倒序:", close[::-1])
mask = close > close.mean()
print("平均价格:", close.mean())
print("是否高于平均:", mask)
print("高于平均的价格:", close[mask])
前三个: [12.79 12.81 12.86] 最后两个: [12.84 12.75] 倒序: [12.75 12.84 12.86 12.81 12.79] 平均价格: 12.81 是否高于平均: [False False True True False] 高于平均的价格: [12.86 12.84]

下面的筛选同时使用第一列和第四列,模拟“银行股价格超过阈值且另一只股票价格也超过阈值”的条件。

prices = np.array([
    [12.79, 1683.25, 180.71, 42.19],
    [12.81, 1690.43, 181.61, 42.36],
    [12.86, 1690.49, 180.83, 42.07]
])
selected = prices[(prices[:, 0] > 12.80) & (prices[:, 3] > 42.10)]
print(selected)
[[ 12.81 1690.43 181.61 42.36]]

6. 广播:让不同形状的数据批量计算

广播允许形状兼容的数组批量运算,不需要手动复制。二维收益矩阵形状为 (日期, 股票),权重形状为 (股票,),因此每一行都会自动使用同一套权重。

returns = np.array([
    [0.010, -0.005, 0.020, 0.004],
    [-0.002, 0.008, -0.010, 0.006]
])
weights = np.array([0.25, 0.25, 0.30, 0.20])
weighted = returns * weights
portfolio = weighted.sum(axis=1)
print("逐项加权:\n", weighted)
print("组合收益:", portfolio)
逐项加权: [[ 0.0025 -0.00125 0.006 0.0008 ] [-0.0005 0.002 -0.003 0.0012 ]] 组合收益: [0.00805 0.0007 ]

如果权重写成 3 个数字,形状无法对齐,就会得到维度错误。先打印 .shape,再修正数组,而不是盲目让 AI 改代码。

base_price = np.array([12.8, 1680.0, 180.0, 42.0])
change = np.array([0.01, -0.02, 0.03, 0.00])
scenario_prices = base_price * (1 + change)
print(scenario_prices)
scenario_change = np.array([
    [0.01, 0.01, 0.01, 0.01],
    [-0.02, 0.00, 0.03, 0.01],
    [-0.05, -0.03, 0.08, -0.02]
])
portfolio_value = 1_000_000 * (1 + scenario_change @ weights)
print(portfolio_value)
[ 12.928 1646.4 185.4 42. ] [1010000. 1013000. 1013000.]

7. 统计函数:从价格数组读出风险特征

均值回答“典型水平”,中位数减少极端值影响,标准差回答“分散程度”,百分位数帮助识别尾部风险。累计收益要用连续复利。

daily_returns = returns_matrix[:, 0]
print("均值:", np.mean(daily_returns))
print("中位数:", np.median(daily_returns))
print("标准差:", np.std(daily_returns, ddof=1))
print("最小值:", np.min(daily_returns))
print("最大值:", np.max(daily_returns))
print("第 25 百分位:", np.percentile(daily_returns, 25))
print("累计收益:", np.prod(1 + daily_returns) - 1)

mean_by_stock = returns_matrix.mean(axis=0)
std_by_stock = returns_matrix.std(axis=0, ddof=1)
statistics = np.column_stack([mean_by_stock, std_by_stock,
                              returns_matrix.min(axis=0),
                              returns_matrix.max(axis=0)])
print("列依次为均值、标准差、最小值、最大值")
print(np.round(statistics, 6))
均值: 0.0002894372 中位数: 0.0003940110 标准差: 0.0048016008 最小值: -0.0070093458 最大值: 0.0062992126 第 25 百分位: -0.0039182143 累计收益: 0.0039093041 列依次为均值、标准差、最小值、最大值 [[ 0.0003 0.0038 -0.0070 0.0055] [ 0.0004 0.0050 -0.0072 0.0074] [ 0.0005 0.0028 -0.0055 0.0051] [ 0.0001 0.0069 -0.0068 0.0057]]

axis=0 沿日期方向压缩,每列得到一只股票的统计量;axis=1 沿股票方向压缩,每行得到一个日期的统计量。

8. 相关系数、协方差与风险分散

两只股票可能各自波动很大,但如果不是同方向变化,把它们放在一起可能降低组合波动。NumPy 可以计算协方差矩阵和相关系数矩阵。

covariance = np.cov(returns_matrix, rowvar=False, ddof=1)
correlation = np.corrcoef(returns_matrix, rowvar=False)
print("协方差矩阵:\n", np.round(covariance, 8))
print("相关系数矩阵:\n", np.round(correlation, 3))
portfolio_variance = weights @ covariance @ weights
portfolio_volatility = np.sqrt(portfolio_variance)
print("组合方差:", portfolio_variance)
print("组合日波动率:", portfolio_volatility)
协方差矩阵:一个 4×4 的对称矩阵,对角线是各股票方差 相关系数矩阵:对角线为 1,其余元素位于 -1 和 1 之间 组合方差: 0.0000132236 组合日波动率: 0.0036364314

协方差单位是收益率平方,相关系数已经标准化。公式 wᵀΣw 把权重向量、协方差矩阵和权重向量组合起来;* 是逐元素相乘,@ 才是矩阵乘法。

9. 线性代数:矩阵乘法与方程组

金融模型常写成矩阵形式。例如多资产组合收益是 R w,最小二乘估计中会出现矩阵转置和方程组。先学会查看形状,再逐渐学习模型。

R = np.array([[0.01, 0.02],
              [0.03, -0.01],
              [-0.02, 0.04]])
w = np.array([0.6, 0.4])
print("每期组合收益:", R @ w)
print("R 的转置形状:", R.T.shape)
print("R.T @ R:\n", R.T @ R)

A = np.array([[2.0, 1.0], [1.0, 3.0]])
b = np.array([100.0, 120.0])
x = np.linalg.solve(A, b)
print("解:", x)
print("验证 A @ x:", A @ x)
每期组合收益: [0.014 0.014 0.004] R 的转置形状: (2, 3) R.T @ R: [[0.0014 0.0001] [0.0001 0.0021]] 解: [36. 28.] 验证 A @ x: [100. 120.]

矩阵的每一行和每一列都应有清楚的经济含义。不要为了让代码运行而随意转置数组。

10. 随机数与可复现的模拟

模拟数据、蒙特卡洛分析和压力测试都需要随机数。课堂示例必须设置种子,这样每次运行得到同样结果,别人才能复核。

from numpy.random import default_rng

rng = default_rng(20260106)
shocks = rng.normal(loc=0.0005, scale=0.01, size=(3, 4))
print(np.round(shocks, 4))
print("每个情景平均冲击:", np.round(shocks.mean(axis=1), 4))
生成 3 个情景、4 只股票的冲击矩阵;每次使用同一个种子,结果完全一致。

loc 是平均水平,scale 是波动规模,size=(3,4) 表示情景数和股票数。随机数只是把假设转化为可计算的样本。

11. 缺失值、无穷值和数据检查

缺失值 NaN 会传播到很多运算;零价格、负成交量和无穷值则可能说明数据口径或代码有问题。

sample = np.array([0.01, np.nan, 0.02, np.inf, -0.01])
print("是否缺失:", np.isnan(sample))
print("是否有限:", np.isfinite(sample))
print("普通均值:", np.mean(sample))
clean = sample[np.isfinite(sample)]
print("忽略异常后的均值:", np.mean(clean))
assert np.all(clean > -1), "收益率低于 -100%,请检查数据"
print("清洗后的数组:", clean)
是否缺失: [False True False False False] 是否有限: [ True False True False True] 普通均值: nan 忽略异常后的均值: 0.006666666666666667 清洗后的数组: [ 0.01 0.02 -0.01]

清洗规则要写进 Notebook。研究中还应追溯缺失值和无穷值为什么出现,而不是直接把所有异常替换成 0。

12. 从 NumPy 计算回到金融判断

先确认输入

价格是否复权?收益率是小数还是百分比?权重是否加总为 1?

再确认形状

日期放在行还是列?axis=0 和 axis=1 分别压缩了什么?

最后解释输出

统计量描述的是样本,不自动等于因果关系、预测能力或投资建议。

NumPy 的学习路线已经从一个数字扩展到数组、矩阵、统计量、随机情景和金融解释。Pandas 会负责把真实表格整理成适合这些计算的形状。

13. 练习:把数组能力用起来

练习 E:矩阵形状

构造一个 5×4 的随机收益矩阵,打印 ndim、shape 和每列标准差。

练习 F:广播检查

将四只股票的市值数组与一个 3×4 的情景涨跌幅矩阵相乘,得到每个情景的市值变化。

练习 G:相关性与分散

比较等权组合和只持有波动率最高股票的标准差,写出风险分散的含义。

练习 H:数据审计

人为插入一个 np.nan 和一个负价格,写出检查代码,说明为什么不能直接用 0 填充。

15. 从单只股票到投资组合

假设把资金分成四份:平安银行 25%、贵州茅台 25%、宁德时代 30%、中国平安 20%。组合每天的收益率是各资产收益率按权重加总。

weights = np.array([0.25, 0.25, 0.30, 0.20])
portfolio_returns = returns_matrix @ weights

mean_daily = portfolio_returns.mean()
vol_daily = portfolio_returns.std(ddof=1)
annual_return = mean_daily * 252
annual_vol = vol_daily * np.sqrt(252)

print("组合平均日收益率:", round(mean_daily, 6))
print("年化收益率近似:", round(annual_return, 4))
print("年化波动率近似:", round(annual_vol, 4))
组合平均日收益率: 0.000274 年化收益率近似: 0.0692 年化波动率近似: 0.0577

这里使用 252 个交易日进行年化只是教学中的近似。模拟数据只有 14 个日收益率,不能据此声称真实年化收益率就是这个数。代码的目的,是让你看清“价格 → 收益 → 权重 → 组合风险”的计算链条。

不要把模拟结果当成投资建议。真实分析还要考虑交易成本、涨跌停、停牌、流动性、数据修订、样本外检验和风险承受能力。

16. 用数组做一个小型压力测试

为了理解风险,可以把每只股票的收益波动放大或缩小,观察组合是否仍能承受。这里不模拟复杂市场,只练习“同一组数组被重复计算”。

scenarios = np.array([0.8, 1.0, 1.2])
for scale in scenarios:
    scenario_returns = returns_matrix * scale
    scenario_portfolio = scenario_returns @ weights
    print(f"波动情景 {scale:.1f}:组合标准差 = {scenario_portfolio.std(ddof=1):.6f}")
波动情景 0.8:组合标准差 = 0.002909 波动情景 1.0:组合标准差 = 0.003636 波动情景 1.2:组合标准差 = 0.004364

当 scale 从 0.8 变为 1.2,所有收益波动被放大,组合标准差也随之放大。这是一个非常简化的压力测试:真实压力测试还应改变相关性、跳跃风险和流动性,而不是只乘一个数。

17. 综合练习:先预测结果,再运行代码

练习 A:改变权重

把贵州茅台权重改为 0.40,并让四个权重仍然加总为 1。运行后比较组合波动率。先写一句你对结果的预测,再解释实际输出。

练习 B:只看一只股票

选择 300750.SZ,计算它的平均日收益率、最大单日上涨和最大单日下跌。

练习 C:检查维度

故意把权重写成 3 个数字,观察矩阵乘法报什么错。不要只复制错误给 AI,要先读懂“维度不匹配”的含义。

练习 D:解释局限

用三句话说明为什么 15 个模拟日期不能支持长期投资结论。

下一节会把长表重新整理成 DataFrame,用筛选、分组、合并和透视表完成同一案例的业务分析。