Files

43 KiB
Raw Permalink Blame History

Python程序设计与数据处理 Class 2 课后练习

数据:datasets/000001.csv(上证综指日度行情,1990-12-19 起)

数据准备

In [1]:
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

pd.set_option('display.max_columns', None)
In [2]:
data = pd.read_csv('datasets/000001.csv')
data['Day'] = pd.to_datetime(data['Day'], format='%Y/%m/%d')
data = data.set_index('Day').sort_index()
data
Out [2]:
Preclose Open Highest Lowest Close
Day
1990-12-19 96.050 99.980 95.790 99.980
1990-12-20 99.98 104.300 104.390 99.980 104.390
1990-12-21 104.39 109.070 109.130 103.730 109.130
1990-12-24 109.13 113.570 114.550 109.130 114.550
1990-12-25 114.55 120.090 120.250 114.550 120.250
... ... ... ... ... ...
2026-09-24 3936.52 3925.323 3930.496 3888.374 3888.374
2026-09-28 3888.374 3878.409 3878.409 3806.671 3823.621
2026-09-29 3823.621 3816.154 3843.838 3810.812 3830.451
2026-09-30 3830.451 3839.253 3851.217 3833.086 3842.195
2026-10-08 3842.195 3838.977 3864.118 3795.374 3811.904

8737 rows × 5 columns

In [3]:
data_new = data['1995-01':'2025-08'].copy()
data_new['Close'] = pd.to_numeric(data_new['Close'])
data_new['Preclose'] = pd.to_numeric(data_new['Preclose'])
data_new['Raw_return'] = data_new['Close'] / data_new['Preclose'] - 1
data_new['Log_return'] = np.log(data_new['Close']) - np.log(data_new['Preclose'])
data_new[['Close', 'Preclose', 'Raw_return', 'Log_return']].head()
Out [3]:
Close Preclose Raw_return Log_return
Day
1995-01-03 639.88 647.87 -0.012333 -0.012409
1995-01-04 653.81 639.88 0.021770 0.021536
1995-01-05 646.89 653.81 -0.010584 -0.010641
1995-01-06 640.76 646.89 -0.009476 -0.009521
1995-01-09 626.00 640.76 -0.023035 -0.023305

题目 1(对数收益率可加性)

取 2010-01-01 到 2010-12-31 的数据,分别用两种方法计算 2010 年全年的对数收益率,并验证它们相等:

  • 方法 A:data_new['Log_return'].loc['2010'].sum()
  • 方法 B:np.log(年末收盘价 / 年初前一日收盘价)
In [4]:
log_sum = data_new['Log_return'].loc['2010'].sum()

last_close = data_new.loc['2010', 'Close'].iloc[-1]
prev_close = data_new.loc[:'2009-12-31', 'Close'].iloc[-1]
log_direct = np.log(last_close / prev_close)

print(f'方法A(对数收益率求和): {log_sum:.12f}')
print(f'方法B(首尾价格直接计算): {log_direct:.12f}')
print(f'差异: {abs(log_sum - log_direct):.2e}')
方法A(对数收益率求和): -0.154470115020
方法B(首尾价格直接计算): -0.154470115020
差异: 3.61e-16

两者在浮点精度范围内相等(差异约 $10^{-16}$),验证了对数收益率的可加性:多期对数收益率等于各期对数收益率之和。

题目 2(首日收益率为什么是 NaN)

计算 data_new['Close'].pct_change(),观察第一个值为什么是 NaN。

  • 解释:为什么首日的收益率应该是 NaN?
  • 如果强行用 fillna(0) 填充,会带来什么问题?
In [5]:
pct = data_new['Close'].pct_change()
pct.head()
Out [5]:
Day
1995-01-03         NaN
1995-01-04    0.021770
1995-01-05   -0.010584
1995-01-06   -0.009476
1995-01-09   -0.023035
Name: Close, dtype: float64

首日(1995-01-03)没有“前一日”的价格可供比较,pct_change() 只能用缺失值表示“无法计算”,所以是 NaN。用 fillna(0) 会把“不知道”伪装成“当天收益为 0”,凭空多出一个假样本;本数据只缺 1 个观测,影响有限,但若数据中间存在停牌、缺日等缺口,这种填充会把缺口全部填成“0 收益”,既掩盖数据质量问题,又让均值、标准差等统计量系统性失真。下面看影响:

In [6]:
print(f"含首个 NaN 时:均值={pct.mean():.6f}, 标准差={pct.std():.6f}, 非缺失样本={pct.notna().sum()}")
masked = pct.fillna(0)
print(f"fillna(0) 后:均值={masked.mean():.6f}, 标准差={masked.std():.6f}, 非缺失样本={masked.notna().sum()}")
含首个 NaN 时:均值=0.000374, 标准差=0.016332, 非缺失样本=7444
fillna(0) 后:均值=0.000374, 标准差=0.016330, 非缺失样本=7445

累积收益不受影响(首行乘以 1+0=1),但统计量已经受到污染(本数据只缺 1 个观测,变化很小;缺口越多影响越大)——把“没有数据”当成“0 收益”本质上是在伪造观测值。

题目 3(np.where 打标签)

用 np.where 给 2020 年的日收益率打标签:收益率为正标为"涨",否则标为"跌"。

  • 统计 2020 年上涨和下跌的天数各是多少?
  • 上涨天数占比是否等于 50%?为什么?
In [7]:
d20 = data_new.loc['2020'].copy()
d20['Direction'] = np.where(d20['Raw_return'] > 0, '涨', '跌')

d20['Direction'].value_counts()
Out [7]:
Direction
涨    131
跌    112
Name: count, dtype: int64
In [8]:
ratio = (d20['Direction'] == '涨').mean()
print(f'上涨天数占比: {ratio:.2%}')
上涨天数占比: 53.91%

2020 年共 243 个交易日:上涨 131 天、下跌 112 天,上涨占比约 53.9%,不等于 50%。原因有二:一是股票收益率的分布并非对称,均值受少数大幅上涨日驱动(正偏态);二是 2020 年全年指数整体上涨,涨跌天数自然偏向上涨。

题目 4(周度收益率)

用 resample('W-FRI') 计算 2020 年上证指数的周度收益率(原始收益率和对数收益率都要)。

  • 一年大约有多少个交易周?
  • 周度收益率的波动比日度收益率大还是小?为什么?
In [9]:
weekly = pd.DataFrame({'Close': d20['Close'].resample('W-FRI').last()})
weekly['Weekly_raw'] = weekly['Close'].pct_change()
weekly['Weekly_log'] = d20['Log_return'].resample('W-FRI').apply(lambda s: s.sum() if len(s) else np.nan)
weekly.head()
Out [9]:
Close Weekly_raw Weekly_log
Day
2020-01-03 3083.79 NaN 0.010978
2020-01-10 3092.29 0.002756 0.002753
2020-01-17 3075.50 -0.005430 -0.005444
2020-01-24 2976.53 -0.032180 -0.032709
2020-01-31 NaN NaN NaN
In [10]:
bins_with_data = d20['Close'].resample('W-FRI').count()
print(f'W-FRI 自然周数: {len(weekly)}')
print(f'其中有交易日的周数: {(bins_with_data > 0).sum()}')
print(f'可计算原始周收益率的周数: {weekly["Weekly_raw"].notna().sum()}')
print(f'周度原始收益率标准差: {weekly["Weekly_raw"].std():.4%}')
print(f'日度原始收益率标准差: {d20["Raw_return"].std():.4%}')
W-FRI 自然周数: 53
其中有交易日的周数: 52
可计算原始周收益率的周数: 50
周度原始收益率标准差: 2.6844%
日度原始收益率标准差: 1.3085%

按 W-FRI 划分,2020 年落在 53 个自然周(周箱子)中,其中 52 周有交易日——2020-01-31 那一周因春节假期延长没有任何交易日。可计算的原始周收益率有 50 个:首周(2020-01-03)缺少上一周收盘价,春节空白周及其后一周也因没有可比基准而为 NaN。周度收益率的绝对波动明显大于日度(约 2.68% vs 1.31%),因为统计区间从 1 天拉长到约 5 天,价格变化自然更大——这也符合“波动随时间跨度按 \sqrt{T} 放大”的规律。另外首周的对数周收益率(1.10%)仍可由当周两个交易日的日对数收益率累加得到,只是它在“周收益率”口径下没有上一周收盘价作基准。

题目 5(np.select 多分类)

用 np.select 把 2020 年每一天的收益率分为 5 类:

  • 大涨:> 2%
  • 小涨:0 ~ 2%
  • 平盘:= 0(如果存在)
  • 小跌:-2% ~ 0
  • 大跌:< -2%

统计每一类的天数。

In [11]:
conditions = [
    d20['Raw_return'] > 0.02,
    d20['Raw_return'] > 0,
    d20['Raw_return'] == 0,
    d20['Raw_return'] >= -0.02,
]
choices = ['大涨', '小涨', '平盘', '小跌']

d20['Magnitude'] = np.select(conditions, choices, default='大跌')
counts = d20['Magnitude'].value_counts().reindex(['大涨', '小涨', '平盘', '小跌', '大跌'], fill_value=0)
print(counts)
print(f'合计: {counts.sum()} 天')
Magnitude
大涨     14
小涨    117
平盘      0
小跌    104
大跌      8
Name: count, dtype: int64
合计: 243 天

2020 年没有收益率恰好为 0 的交易日(平盘 0 天)。大涨 14 天、大跌 8 天,涨跌幅超过 2% 的极端日共 22 天,分布并不均匀:2–3 月疫情冲击期间连续出现大跌和大涨(2 月 3 天、3 月 6 天),7 月快速上涨阶段又集中出现 7 天,其余月份各 1 天左右。

题目 6(累积收益率的两种方法)

用两种方法计算 2020 年上证指数的累积收益率曲线:

  • 方法 A:np.exp(Log_return.cumsum()) - 1
  • 方法 B:(1 + Raw_return).cumprod() - 1

验证两种方法结果是否相同(用 np.allclose)。如果不同,差在哪里?

In [12]:
cumA = np.exp(d20['Log_return'].cumsum()) - 1
cumB = (1 + d20['Raw_return']).cumprod() - 1

print(f'np.allclose: {np.allclose(cumA, cumB)}')
print(f'最大绝对差异: {(cumA - cumB).abs().max():.2e}')
print(f'年末累积收益率 A/B: {cumA.iloc[-1]:.6f} / {cumB.iloc[-1]:.6f}')
np.allclose: True
最大绝对差异: 1.78e-15
年末累积收益率 A/B: 0.138667 / 0.138667
In [13]:
comparison = pd.DataFrame({'cumA': cumA, 'cumB': cumB})
comparison['diff'] = comparison['cumA'] - comparison['cumB']
comparison.iloc[[0, -1]]
Out [13]:
cumA cumB diff
Day
2020-01-02 0.011501 0.011501 6.661338e-16
2020-12-31 0.138667 0.138667 -2.220446e-16

两种方法结果相同(allclose=True),仅在最后一位浮点数上有约 10^{-15} 的舍入差异,来自 exp/log 与乘法的运算次序不同。数学上 $e^{\sum r_t}=\prod(1+R_t)$,二者等价。

题目 7(滚动收益率的最大值)

计算 2020 年滚动 20 日收益率(基于对数收益率累加),找出收益率最高和最低的日期分别是哪一天。

In [14]:
rolling_20 = np.exp(d20['Log_return'].rolling(window=20).sum()) - 1

print(f'有效值个数: {rolling_20.notna().sum()}(前 19 个交易日因窗口不足为 NaN)')
print(f'最高: {rolling_20.idxmax():%Y-%m-%d},收益率 {rolling_20.max():.4%}')
print(f'最低: {rolling_20.idxmin():%Y-%m-%d},收益率 {rolling_20.min():.4%}')
有效值个数: 224(前 19 个交易日因窗口不足为 NaN)
最高: 2020-07-13,收益率 17.8846%
最低: 2020-03-23,收益率 -12.2412%

2020 年滚动 20 日收益率最高出现在 2020-07-13(约 +17.9%,对应 7 月初的快速上涨),最低出现在 2020-03-23(约 -12.2%,对应疫情引发的全球市场暴跌)。

题目 8(索引对齐)

构造下面两个 DataFrame 并相加,观察结果:

df1 = pd.DataFrame({'v': [1, 2, 3]}, index=['A', 'B', 'C'])
df2 = pd.DataFrame({'v': [10, 20, 30]}, index=['B', 'C', 'D'])
  • 结果中哪些索引是 NaN?

  • 用什么方法可以让所有 4 个索引都有值?(提示:.add(fill_value=0))

In [15]:
df1 = pd.DataFrame({'v': [1, 2, 3]}, index=['A', 'B', 'C'])
df2 = pd.DataFrame({'v': [10, 20, 30]}, index=['B', 'C', 'D'])

df1 + df2
Out [15]:
v
A NaN
B 12.0
C 23.0
D NaN
In [16]:
df1.add(df2, fill_value=0)
Out [16]:
v
A 1.0
B 12.0
C 23.0
D 30.0

直接相加按索引对齐取交集:A、D 只存在于其中一个表中,结果为 NaN;B、C 正常相加。用 add(fill_value=0) 把缺失一侧视为 0,A 得 1、D 得 30,四个索引就都有值了。

题目 9(前视偏差)

用 shift(1) 和 shift(-1) 分别构造两个滞后收益率,然后各跑一次 OLS:

In [17]:
d20['l1'] = d20['Raw_return'].shift(1)
d20['l_1'] = d20['Raw_return'].shift(-1)

d20[['Raw_return', 'l1', 'l_1']].head()
Out [17]:
Raw_return l1 l_1
Day
2020-01-02 0.011501 NaN -0.000457
2020-01-03 -0.000457 0.011501 -0.000123
2020-01-06 -0.000123 -0.000457 0.006937
2020-01-07 0.006937 -0.000123 -0.012210
2020-01-08 -0.012210 0.006937 0.009127

分别用 smf.ols('Raw_return ~ l1', ...) 和 smf.ols('Raw_return ~ l_1', ...) 拟合 2020 年数据。哪个 R² 更高?为什么前视偏差会让你"看起来"能预测收益率?这是真实的预测能力吗?

In [18]:
model_lag = smf.ols('Raw_return ~ l1', data=d20).fit()
model_look = smf.ols('Raw_return ~ l_1', data=d20).fit()

print(f'使用滞后收益 l1  : R2 = {model_lag.rsquared:.6f},系数 = {model_lag.params["l1"]:.6f}')
print(f'使用未来收益 l_1 : R2 = {model_look.rsquared:.6f},系数 = {model_look.params["l_1"]:.6f}')
使用滞后收益 l1  : R2 = 0.003048,系数 = 0.055317
使用未来收益 l_1 : R2 = 0.003048,系数 = 0.055108

在本数据上两者几乎相等(R² 都约 0.003,差别只来自样本端点)。原因:对同一条收益率序列,corr(r_t, r_{t-1}) 与 corr(r_t, r_{t+1}) 是同一个一阶自相关系数,回归的 R² 自然相同。所以单看 R²,「用未来解释现在」并不会自动变高。

前视偏差真正危险的地方在于:把 t+1 的信息当作 t 时刻可用的信号去构建策略。例如用 l_1 作为交易信号,等价于在收盘前就知道明后天的涨跌,实盘根本无法执行;又比如若把预测目标本身误当作特征,R² 会直接等于 1。下面的例子更直观——用明天的收益去"预测"明天的收益:

In [19]:
perfect = smf.ols('Raw_return ~ l_1', data=d20.assign(Raw_return=d20['Raw_return'].shift(-1))).fit()
print(f'把预测目标也设成未来收益时 R2 = {perfect.rsquared:.6f}')
把预测目标也设成未来收益时 R2 = 1.000000

这种「完美预测」来自偷看答案,不是模型能力。判断特征是否可用,关键看它在做出决策的那一刻是否已知,而不是拟合优度有多高。

题目 10

假设你在 2020 年第一个交易日买入上证指数,一直持有到 2020 年最后一个交易日。

  • 计算这笔投资的全年累积收益率。
  • 如果当时投入 10 万元,年底变成多少钱?
In [20]:
buy_close = d20['Close'].iloc[0]
hold_close = d20['Close'].iloc[-1]

ret = hold_close / buy_close - 1
print(f'买入日: {d20.index[0]:%Y-%m-%d},收盘价 {buy_close:.2f}')
print(f'卖出日: {d20.index[-1]:%Y-%m-%d},收盘价 {hold_close:.2f}')
print(f'全年累积收益率: {ret:.4%}')
print(f'10 万元变为: {100000 * (1 + ret):,.2f} 元')
print(f'(若把买入日当天自身的涨幅也算入,则为 {np.exp(d20["Log_return"].sum()) - 1:.4%})')
买入日: 2020-01-02,收盘价 3085.20
卖出日: 2020-12-31,收盘价 3473.07
全年累积收益率: 12.5720%
10 万元变为: 112,571.96 元
(若把买入日当天自身的涨幅也算入,则为 13.8667%)

以第一个交易日收盘价买入、最后一个交易日收盘价卖出,2020 年累积收益率约 12.57%,10 万元变成约 112,571.96 元。若把买入日当天的涨跌也算进持有期,收益率为 13.87%,两者的比值正好对应 2020-01-02 当天的 1.15% 涨幅(1.138667 / 1.125720 − 1 ≈ 1.15%)。