2026年6月12日,SPY指数收盘时上涨了0.54%;而EchoStar公司的股价下跌了11%,Lennar公司的股价则下降了4.9%。指数中其余的500只股票,其涨跌幅度几乎都与SPY指数的表现相符。
这种差异正是本文所要探讨的核心内容。每只股票都与市场存在一种固定的关系:当SPY指数上涨时,这些股票往往也会随之上涨;而当SPY指数下跌时,它们往往会随之下跌。
一旦了解了这种关系,你就可以计算出某只股票在特定日子里应该达到的表现,然后将其与实际表现进行对比。对于大多数股票来说,大多数时间里这种差异几乎为零;但对于少数股票而言,这种差异会相当显著,而正是这些差异才构成了真正值得关注的地方。
本文编写了一个Python脚本,该脚本每天都会对整个标准普尔500指数中的所有股票进行这样的对比分析,会标记出那些预期收益与实际收益之间差距最大的股票,并进一步探究是哪些新闻、交易量变化或行业动态导致了这种差异。
目录
先决条件
要顺利完成本文中的分析,你需要掌握Python基础知识、pandas数据框的使用方法、循环结构、函数编写技巧,以及使用matplotlib进行简单的数据可视化操作。
<您还需要准备以下物品:>
-
Python 3.9或更高版本
-
一个EODHD API密钥
-
以下Python库:`requests`、`pandas`、`numpy`、`matplotlib`和`statsmodels`
-
需要具备对日收益率、贝塔系数、阿尔法系数、交易量、Z分数以及股票代码等基本概念的了解
你不需要掌握高级的量化金融知识。我们的目标是构建一个实用的工具,该工具能够区分由市场因素导致的股价变动与特定股票自身的特性所引起的变动,并进一步判断交易量和新闻是否能够帮助解释那些异常的股价变化。
准备工作:导入所需包
import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm
from statsmodels.regression.rolling import RollingOLS
plt.style.use('ggplot')
`requests`和`pandas`用于处理API请求及所有数据整理工作。`statsmodels`中的`RollingOLS`模块可以用来进行滚动回归分析,从而计算出每只股票相对于SPY指数的贝塔系数和阿尔法系数,这是这个工具的核心功能。使用`ggplot`可以让生成的图表看起来更加美观。
构建标准普尔500指数成分股列表
这个工具需要一份当前的标准普尔500指数成分股的名单以及它们所属的行业类别。通过访问EODHD的基本数据接口,可以直接获取这些信息。
api_key = 'eodhd api key'
url = f'https://eodhd.com/api/fundamentals/GSPC.INDX?api_token={api_key}&fmt=json&filter=Components'
r = requests.get(url)
components = r.json()
universe = pd.DataFrame(components).T[['Code', 'Sector]].rename(columns={
'Code': 'ticker',
'Sector': 'industry'
}).reset_index(drop=True)
tickers = universe['ticker'].tolist()
print(f'universe size: {len(universe)}')
print(universe['sector'].value_counts())
输出结果:
universe size: 503
industry
Technology 83
Industrials 75
Financial Services 70
Healthcare 59
Consumer Cyclical 54
Consumer Defensive 35
Utilities 31
Real Estate 31
Communication Services 24
Energy 21
Basic Materials 20
Name: count, dtype: int64
最终得到了503只股票代码。因为标准普尔500指数中包含了一些具有双重股权结构的股票。其中,科技行业和工业行业的股票数量加起来占据了指数的近三分之一,这一点在后续分析中非常重要——因为当某些股价变动集中在某个特定行业时,这一信息会起到关键作用。
SPY指数的相关数据会在后续步骤中单独获取,它不会被纳入这份列表中。SPY指数只是我们的参考基准,并非分析对象之一。
获取股票价格、交易量及日收益率
进行回归分析时,需要获取宇宙中每一个股票代码的一整年的价格和成交量数据,同时还需要以SPY作为基准。这些历史数据可以通过EODHD提供的历史数据端点来获取。
end_date = pd Timestamp.today().strftime('%Y-%m-%d')
start_date = (pdTimestamp.today() - pd.Timedelta(days=365)).strftime('%Y-%m-%d')
def fetch_ohlcv(ticker, start, end):
url = f'https://eodhd.com/api/eod/{ticker}.US?from={start}&to={end}&api_token={api_key}&fmt=json'
r = requests.get(url)
data = r.json()
df = pd.DataFrame(data)[['date', 'adjusted_close', 'volume']]
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
df.columns = [ticker, f'{ticker}_vol']
return df
all_prices = {}
all_volumes = {}
for ticker in tickers + ['SPY']:
try:
result = fetch_ohlcv(ticker, start_date, end_date)
all_prices[ticker] = result[ticker]
all_volumes[ticker] = result[f'{ticker}_vol']
print(f'{ticker} 已完成')
except:
print(f'{ticker} 出现错误')
prices = pd.DataFrame(all_prices)
volumes = pd.DataFrame(all_volumes)

循环结束后,会得到两个数据框:一个包含价格数据,另一个包含成交量数据。这两个数据框都按照日期进行排序,而且每个股票代码都对应着一列数据。由于统计的时间跨度为整整一年,共251个交易日,因此数据框一共有504列——因为503只标准普尔500指数成分股加上SPY的所有数据都成功被获取到了。
计算每日收益率
经过调整后的收盘价可以直接用来计算每日百分比收益率,而回归分析实际上也是使用这些收益率来进行计算的,并非原始价格。
prices = prices.sort_index()
volumes = volumes.sort_index()
prices = prices.apply(pd.to_numeric, errors='coerce')
volumes = volumes.apply=pd.to_numeric, errors='coerce')
prices = prices.ffill(limit=3)
returns = prices.pct_change(fill_method=None)
returns = returns.iloc[1:]
missing pct = returns.isna().mean()
valid_tickers = missing pct[missingpct <= 0.10].index.tolist()
if 'SPY' not in valid_tickers:
valid_tickers.append('SPY')
returns = returns[valid_tickers]
volumes = volumes[valid_tickers]
spy_returns = returns['SPY']
stock_returns = returns.drop(columns=['SPY'])
stock_returns.head()

有少数股票代码在某些日子出现了NaN价格。这种一次性出现的数据缺失情况,在对503只股票进行数据收集时是不可避免的。
在计算百分比收益率之前,ffill(limit=3)这个函数会用来填补这些价格缺口,且填补的范围最多为三个交易日。因此,根据这个方法计算出的收益率实际上反映了一个假设:如果没有新的价格数据,那么收益率就不会发生变化,而不是通过人为填充数据来制造出虚假的收益率数值。
任何缺失时间超过三天的数据,在returns中仍会显示为NaN,并且会被剔除掉(因为超过了10%的缺失阈值),而不会被进行修复。
pct_change参数中的fill_method=None也很重要。因为如果不对这个参数进行设置,pandas会在自行计算差异之前先进行前向填充,而这正是这次修复所要避免的情况。在应用过滤规则后,有两个股票代码的计算结果变成了501而不是503,而这两个数值都超过了缺失阈值的范围。
估算滚动贝塔值和阿尔法值
每一只股票对SPY的变动都会产生相应的反应:当市场发生变化时,这只股票往往会随之波动。贝塔值就是用来衡量这种反应程度的,而使用60天的滚动窗口进行计算,可以得到一个稳定的估算结果,而不会因为某一天市场的异常波动而产生过度反应。RollingOLS可以一次性为所有股票代码计算出这些数值。
window = 60
rolling_beta = pd.DataFrame(np.nan, index=stock_returns.index, columns=stock_returns.columns)
rolling_alpha = pd.DataFrame(np.nan, index=stock_returns.index, columns=stock_returns.columns)
spy_with_const = sm.add_constant(spyreturns)
for ticker in stock_returns.columns:
model = RollingOLS(stock_returns[ticker], spy_with_const, window=window).fit()
rolling_beta[ticker] = model.params['SPY']
rolling_alpha[ticker] = model.params['const']
print(f'已估算出贝塔值的股票代码有:{rolling_beta.notna().any().sum()}个')
print(f>估算结果的日期范围为:{rolling_beta.dropna(how="all").index[0].date()}至{rolling_beta.dropna(how="all").index[-1].date()}


sm.add_constant这个方法会在SPY的收益序列中添加一个截距项,这样在进行回归分析时,就可以同时计算出阿尔法值和贝塔值。model.params['SPY']代表的是贝塔值,model.params['const']代表的是阿尔法值,这些数值都是从循环中涉及的每一只股票对应的拟合模型中直接提取出来的。
在6月初,PGR的贝塔值大约在-0.42到-0.53之间,这个数值表明,在那段时间里,这只保险股的走势与市场走势完全相反;而CSX的贝塔值则稳定地保持在0.43到0.49之间,这更符合一只工业股票的典型特征。
计算残差收益
贝塔值和阿尔法值用来表示:在SPY发生变动的情况下,某只股票应该会有怎样的表现。将这种预期收益从该股票实际的收益中减去,剩下的部分就是残差收益——这部分收益与市场的走势无关。
如果使用今天的贝塔值来预测今天的股价走势,那么这种预测结果本身就会影响作为基准的SPY的走势,因此在进行计算之前,需要先将这两者的时间点都向后推移一天再进行分析。
beta_shifted = rolling_beta.shift(1)
alpha-shifted = rolling_alpha.shift(1)
spy-aligned = spy_returns.reindex(stock_returns.index)
expected_returns = alpha_shifted.add(beta_shifted.multiply(spyAligned, axis=0))
residuals = stockreturns - expected_returns


预期收益等于昨天的“阿尔法值”加上昨天的“贝塔值”乘以今天的SPY指数回报,这种预测结果反映了股票在正常市场条件下的应有表现。残差则是指实际收益与这一预测值之间的差异。
这些数值大多处于一个狭窄的范围内,其波动幅度通常只有百分之几。一旦剔除了市场自身因素的影响,这种波动形态恰恰就是市场驱动型走势的真实体现。
使用经漂移校正后的Z分数来评估残差
单独来看,0.03这个数值并没有什么特别的意义。有些股票的波动幅度本来就比较大,因此同样的残差值需要结合该股票自身的近期表现来进行判断,而不能用一个固定的标准来衡量所有股票。
window_z = 20
resid_mean = residuals.shift(1).rolling(window_z).mean()
resid_std = residuals.shift(1).rolling(window_z).std()
zscore = (residuals - resid_mean) / resid_std
zscore.tail()

之所以要同时使用滚动平均值和滚动标准差,是因为有些股票的残差值会存在持续的微小波动,即在这些数值在某个时间段内会略微高于或低于零。如果以这种波动范围作为基准来计算Z分数,就能更准确地反映该股票的实际异常情况。
与计算“贝塔值”和“阿尔法值”时一样,这里的平均值和标准差也会向前移动一天再进行计算,因为今天的数据不能被用来构建包含今天自身数值的统计分布。
例如,AFL在6月8日的Z分数为-2.31,SOLV也在同一天得到了-2.31这个数值,这两个数值已经明显低于它们近期的正常波动范围(即低于平均值的两个标准差),而SOLV在第二天其Z分数又变为了+2.40。
添加多日确认机制
单日的Z分数可能会受到偶然因素的影响,从而出现在正常范围之外。因此,通过计算过去3天或5天的残差变化情况,就可以判断这种波动趋势是否具有持续性。
residuals_3d = (1 + residuals).rolling(3).apply(np.prod, raw=True) - 1
residuals_5d = (1 + residuals).rolling(5).apply(np.prod, raw=True) - 1
print('3天复合残差值(最后5行,前5个股票代码):')
print(residuals_3d.iloc[-5:, :5].round(4))
print('\n5天复合残差值(最后5行,前5个股票代码):')
print(residuals_5d.iloc[-5:, :5].round(4))

在这里,复合效应比简单求和更为重要,因为残差回报会像普通收益一样随时间累积增长。
AIZ的残差值在3天内从2.4%上升到5天内的0.6%,这意味着这种变化主要集中在最近这段时间内,而之前的几天残差值几乎处于中性水平。MNST则呈现相反的趋势:在6月11日之前的三天里,其残差值从2%稳步上升至4.4%,再升至5.8%,这种变化是持续性的,而不是突然出现的。
通过成交量进行验证
当普通交易量产生的残差回报较高时,人们很容易忽略这一现象;但如果参与交易的股票数量是平时的两倍,那么这种变化就值得重视了。成交量能够证明这种变化确实得到了市场的真实响应。
vol_mean = volumes.shift(1).rolling(20).mean()
volume_ratio = volumes / vol_mean
volume_ratio = volume_ratio.drop(columns=['SPY'], errors='ignore')
print('成交量比率(最后5行,前5个股票代码):')
print(volume_ratio.iloc[-5:, :5].round(2))

之所以使用20天的平均成交量作为分母,是因为与这个扫描工具中使用的其他所有滚动统计指标一样,需要将今天的成交量剔除在外,以免过高或过低的成交量影响基准值的准确性。
在这五个股票代码中,没有哪一个在特定的这些日子里出现过超过1.5倍的成交量比率。只有当成交量比率超过1.5,并且其z分数超出2个标准差时,这一信号才具有较高的可信度;单独来看,这两个指标中的任何一个都不足以作为有效的验证依据。
构建阿尔法因子筛选队列
到目前为止,所有收集到的数据都指向同一个交易日。将每个数据集中最近的一行提取出来,并按照股票代码将这些数据合并起来,就可以得到这个扫描工具最终要生成的那个表格。
scan_date = stock_returns.index[-1]
queue = pd.DataFrame({
'sector': universe.set_index('ticker')[‘sector'],
'actual_return': stock_returns.loc[scan_date],
'spy_return': spy_returns.loc[scan_date],
'beta': beta_shifted.loc[scan_date],
'expected_return': expected_returns.loc[scan_date],
'residual': residuals.loc[scan_date],
'zscore': zscore.loc[scan_date],
'residual_3d': residuals_3d.loc[scan_date],
'residual_5d': residuals_5d.loc[scan_date],
'volume_ratio': volume_ratio.loc[scan_date]
})
queue = queue.dropna()
queue = queue.reindex(queue['zscore'].abs().sort_values(ascending=False).index)
queue['high_confidence'] = (queue['zscore'].abs() > 2.0) & (queue['volume_ratio'] > 1.5)
queue.head(10)

有些股票之所以会脱颖而出,原因各不相同。
SATS:成交量异常突出的股票:其股价下跌了近11%,而SPY的涨幅仅为0.5%。如果其贝塔值为1.55,那么它的股价应该只会略有上涨,而不应该出现两位数的跌幅;实际上,它的贝塔值接近-12%。其成交量是其20天平均成交量的6倍多,这一比例在表格中是最高的。
LEN:得分极低的股票:其z分数为-3.9,这是整个榜单中最低的数值。如果其贝塔值为1.45,那么在SPY上涨的当天,它的股价应该会略有上涨;但实际上,它的股价却下跌了近5%。
MOS和ALB:可能存在共同因素的股票:这两只股票都属于基础材料行业,它们的股价都出现了上涨,而且成交量也都明显增加。它们在排名中紧挨着彼此。在将其中任何一只股票视为一种独立的、特殊的市场现象之前,我们有必要先检查是否存在一些共同的因素导致这种走势。
TKO:一个存在疑问的股票:仅从各项数据来看,它的表现确实符合“高置信度”的标准;但是根据不同的信息来源,这个代码对应的其实是两家不同的公司:TKO Group Holdings或Tikehau Capital。一旦开始搜索相关新闻,这种矛盾就会引发实际的问题。
结合新闻来分析这些股票的表现
z分数只能说明某种股价走势是不寻常的,但却无法解释为什么会出现这种走势。要想弄清楚这些数据背后是否真的存在某些原因,唯一的方法就是查找与这些股票相关的最新新闻报道。我们将使用EODHD提供的金融新闻接口来获取这些新闻数据。
def fetch_news(ticker, start, end):
url = f'https://eodhd.com/api/news?s={ticker}.US&from={start}&to={end}&limit=3&api_token={api_key}&fmt=json'
r = requests.get(url)
data = r.json()
return [item['title'] for item in data[:3]]
news_start = (scan_date - pd.Timedelta(days=3)).strftime('%Y-%m-%d')
news_end = scan_date.strftime('%Y-%m-%d')
high_conf = queue[queue['high_confidence]].head(10)
remaining = queue[~queue['high_confidence')).head(max(0, 10 - len(high_conf)))
newscandidates = pd.concat([high_conf, remaining])
news_results = {}
for ticker in news_candidates.index:
headlines = fetch_news(ticker, news_start, news_end)
news_results[ticker] = headlines
print(f'\n{ticker}:')
if headlines:
for h in headlines:
print(f' - {h}')
else:
print(' 未找到相关新闻')
输出结果:
LEN:
- Lennar Corp在2026年第二季度的财报电话会议要点:强劲的利润率以及所采取的战略调整措施……
- 为什么Lennar的股票今天会下跌?
- 最新动态:由于SpaceX的表现优异,股市整体上涨;在人们对伊朗问题达成协议的乐观情绪影响下,华尔街也出现了周度涨幅。
SATS:
- 由于人们期待美国和伊朗能尽快达成临时和平协议,相关股票的股价出现了上涨。
- 6月12日的股市行情:由于与SpaceX相关的股票表现强劲,EchoStar的股价下跌了;而DISH和DBS所面临的支付风险也影响了相关股票的走势。
- 为什么EchoStar的股票今天会下跌?
MOS:
- 在标准普尔500指数中,KLAC和MOS是当日表现较为突出的股票。
• 标准普尔500指数中被过度抛售的前10只股票。
• 自上次发布财报以来,Mosaic的股价已经下跌了5%——它能否反弹呢?
ALB:
• DuPont在美国的医疗保健机构中实现了利用可再生能源的目标。
• 在标准普尔500指数中,KLAC和MOS是当日表现较为突出的股票。
• ATI和BWX Technologies决定将他们的战略合作伙伴关系延长至2030年。
TKO:
• Tikehau Capital公布了其在2026年6月5日至6月11日期间进行的股份回购情况。
• 鉴于TKO Group Holdings即将发放股息,我们对其股票持谨慎态度。
• Tikehau Capital决定继续执行其股份回购计划。
FOX:
• Fox可能会获得800多个世界杯广告投放机会。
• 从经济角度来看,世界杯会对美国产生多大的积极影响呢?
• 自上次发布财报以来,Fox的股价为何上涨了3.3%?
DPZ:
• Domino's的股票估值是否发生了变化?这一变化是否反映了其在市场竞争中的地位正在发生改变?
• 在考虑到Domino's近期表现不佳的情况下,我们应该如何评估其股票的价值?
• 现在是不是购买Domino's Pizza股票的好时机呢?
CTAS:
• UniFirst的股东们已经批准了与Cintas进行的交易。
• 看跌Cintas股票的投资者们似乎忽视了这家公司所拥有的盈利能力。
CTVA:
• Corteva预计重组成本将会增加,因此计划停止其在西班牙的生产活动。
• Zacks行业研究报告重点关注了Corteva、Archer Daniels Midland、The Scotts、Miracle-Gro、Adecoagro以及Mission Produce等公司的发展前景。
• 有5只农业相关股票的业绩将会因创新带来的发展而得到提升。
TSN:
• 由于牛肉供应方面存在问题,再加上其股票估值被低估,Tyson Foods任命了新的首席运营官。
• 尽管牛肉价格创下了历史新高,但JBS仍然决定关闭部分工厂。
• Tyson Foods公司目前正吸引着投资者的关注——以下是您需要了解的信息。
那些置信度较高的股票会首先被纳入考量范围;如果符合标准的股票数量少于10只,那么剩余的位置就会由z分数次高的股票来填补。正因如此,尽管DPZ、CTAS、CTVA和TSN并不具备这些特征,它们仍然会被列在这里。
SATS的表现依然稳定。一条直接的新闻标题将股价下跌与DISH DBS的支付风险联系在了一起,而这条消息恰好出现在相关数据出现的当天,同时也与交易量的激增相吻合。
LEN也同样表现稳定。“为什么Lennar的股票今天会下跌”这样的标题已经是对这一现象最直接的说明了;此外,第二季度财报电话会议的相关内容也进一步解释了市场为何会调整该股票的估值。
TKO却出现了异常。
所有被选中的新闻标题都与Tikehau Capital有关——这家法国资产管理公司的股票代码与TKO Group Holdings在另一个交易所上的代码相同。因此,系统对这类股票的识别是正确的;但新闻搜索却完全找错了目标公司。
MOS和ALB的情况仍然无法得到解释。
关于ALB的新闻标题主要涉及杜邦公司与某些国防项目的合作,但这些内容都与股价的变动无关;至于MOS,虽然有提到“自上次财报发布以来股价下跌了5%”,但也没有任何内容能够解释当天股价为何会发生变化。因此,“共同因素导致股价波动”的这种推测在这里也无法得到证实。
可视化那些表现异常的股票
实际收益与预期收益的对比
只有当某只股票的实际收益偏离了仅通过贝塔系数预测得出的结果时,它才会引起人们的关注。通过将实际收益与预期收益进行对比,就可以快速判断哪些股票属于这种情况。
fig, ax = plt.subplots(figsize=(9, 7))
sector_list = queue['sector'].unique()
colors = plt.cm.tab20(np.linspace(0, 1, len(sector_list)))
sector_colors = dict(zip(sector_list, colors))
for sector in sector_list:
subset = queue[queue['sector'] == sector]
ax.scatter(
subset['expected_return'], subset['actual_return'],
s=subset['volume_ratio'] * 40,
color=sector_colors[sector],
label=sector, alpha=0.7, edgecolors='black', linewidth=0.5
)
lims = [queue[['expected_return', 'actual_return]].min().min(),
queue[['expected_return', 'actual_return')).max().max()]
ax.plot(lims, lims, color='black', linestyle='--', linewidth=1)
ax.set_xlabel('预期收益(经过贝塔系数调整后)')
ax.set_ylabel('实际收益')
ax.set_title(f'实际收益与预期收益的对比 - {scan_date.date()}')
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8)
plt.tight_layout()
plt.show()

在这487个数据点中,大多数都分布在靠近虚线的范围内,也就是实际收益与预期收益大致相符的狭窄区域内。事实上,在考虑了贝塔系数的影响后,任何一天的大部分交易情况都是这样的。
SATS在图表右侧的线条下方位置明显偏低,它是整个图表中规模最大的“气泡”,其大小直接反映了6.28倍的成交量比率这一数据,这一比率进一步证实了它的异常变动。
位于底部附近的那个灰色标记代表LEN,它同样明显偏离了那条参考线,而且其尺寸足够大,因此在那些紧密聚集在对角线附近的“消费周期类股票”指标中显得格外突出。
还有其他一些数据点也在不同方向上显著偏离了那条参考线,但这些数据点并未被标记为“高置信度”的异常值。这一事实提醒我们:仅仅因为某个数据点偏离了参考线,并不能就意味着它一定代表了某种重要的市场现象——而正是通过成交量分析和新闻报道等手段,才能对这些数据点进行进一步的验证。
按Z分数排名前30的异常波动股票
仅凭Z分数排名就可以看出哪些股票的走势在统计上是异常的。但如果将每个数据点的Z分数与其对应的成交量比率结合起来进行分析,就能进一步判断这些异常走势是否真的伴随着实际的交易活动;因为这两者结合在一起所传递的信息,比单独看其中一个指标要更有意义。
top30 = queue.head(30).sort_values('zscore')
fig, ax = plt.subplots(figsize=(8, 6))
bar_colors = ['#2ca02c' if z > 0 else '#d62728' for z in top30['zscore']]
ax.barh(top30.index, top30['zscore'], color=bar_colors)
for i, (ticker, row) in enumerate(top30.iterrows()):
ax.text(row['zscore'] + (0.1 if row['zscore'] > 0 else -0.1),
i, f'vol={row["volume_ratio"]:.1f}倍',
va='center', ha='left' if row['zscore'] > 0 else 'right', fontsize=7)
ax.axvline(2.0, color='black', linestyle='--', linewidth=1)
ax.axvline(-2.0, color='black', linestyle='--', linewidth=1)
ax.set_xlabel('Z分数')
ax.set_title(f'按Z分数排名前30的异常波动股票 - {scan_date.date()}')
plt.tight_layout()
plt.show()

LEN的数值明显低于-3.0这一参考线,而其对应的成交量比率为2.4倍,这一数据也清楚地反映了它的异常走势。
SATS紧随其后,其数值约为-3.0。但真正值得注意的是它的成交量比率——6.3倍,这一比率在整个图表中都是最高的,因此它对确认SATS的异常变动起到了更为有力的作用。
从积极的角度来看,MOS和ALB都位于绿色柱状图的顶部位置,它们的成交量比率也都超过了1.5倍。这一情况与之前的分析结果一致,说明这两只股票可能有着共同的驱动因素。
ADBE则是值得重点关注的股票。它的数值仅略低于-1.6,因此没有达到能被标记为“高置信度”异常值的标准。但它的成交量比率高达4.2倍,这一数据在整个图表中也是名列前茅的。这种结合了中等程度的Z分数和异常高的成交量的情况,正是那些固定阈值所无法捕捉到的,而这样的图表却能够有效地揭示出来。
后期出现的异常收益情况
单日的残差数据无法判断某次股价走势是在持续发展还是已经结束。通过对比同一组股票在1天、3天和5天时间窗口内的残差变化,就可以区分这两种情况。
top15 = queue.head(15)
heatmap_data = top15[['residual', 'residual_3d', 'residual_5d']]
heatmap_data.columns = ['1-day', '3-day', '5-day']
fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(heatmap_data.values, cmap='RdYlGn', aspect='auto', vmin=-0.1, vmax=0.1)
ax.set_xticks(range(len(heatmap_data.columns)))
ax.set_xticklabels(heatmap_data.columns)
ax.set_yticks(range(len(heatmap_data.index)))
ax.set_yticklabels(heatmap_data.index)
ax.grid(False)
for i in range(heatmap_data.shape[0]):
for j in range(heatmap_data.shape[1]):
ax.text(j, i, f'{heatmap_data.values[i, j]:.1%}', ha='center', va='center', fontsize=8)
ax.set_title(f'滞后异常收益 - {scan_date.date()}')
plt.colorbar(im, ax=ax, label='异常收益')
plt.tight_layout()
plt.show()

ALB是一个典型的例子:它的股价走势是逐渐上升的,而不是急剧上涨的——在第一天为7.0%,三天后升至11.8%,五天后稳定在10.5%。每个时间窗口内的残差数据所显示的颜色都会逐渐加深,而不会逆转。
SATS的情况则恰恰相反。1天时间窗口内的残差数据显示为-11.8%(这是整个热图中颜色最深的红色区域),但3天和5天时间窗口内的残差数据分别降至-2.9%和-2.3%。这意味着大部分负面影响在第一个交易日内就已经反映在了股价中,后续几天的波动几乎没有进一步加剧这种负面影响。
CVNA则展示了第三种情况:它的股价走势在先恶化后才好转——第一天为-6.4%,三天后扩大到-8.9%(这是除了SATS之外颜色最深的红色区域),到第五天又回落到-4.4%。
这三个股票分别代表了三种不同的走势模式,而仅通过单日的z分数数据是无法看出这些区别的。
结论与下一步行动建议
今天的分析中有几点值得注意:
-
在487只股票中,有31只股票的指标符合高置信度标准,占比约为6%——对于这种每日筛选系统来说,这个命中率已经相当不错了。
-
SATS和LEN这两只股票的股价走势确实有实质性新闻作为支撑,这是这类筛查工具所能获得的最佳结果。
-
TKO这个例子提醒我们:同一个股票代码可能代表两家不同的公司,这取决于数据来源的不同。
-
MOS和ALB这两只股票的股价走势没有得到任何新闻事件的确认,但它们却出现了同步变动,因此值得我们进一步深入研究,而不仅仅是看一眼表格就草率下结论。
有一些方法可以进一步深入分析这些数据:
-
应该按照公司名称而不是股票代码来匹配相关新闻。只有这样,才能发现TKO这种指标冲突的情况。
-
对于每只股票,应该检索超过3条相关的新闻报道。ALB和MOS这两只股票的相关新闻数量较少,因此这一点尤为重要。
-
应该每天进行这样的分析,并记录下来。单日的数据无法判断某次股价走势是持续发展还是出现了逆转。
-
还可以添加行业层面的分析维度。如果同一行业的两只股票指标同时出现异常变化,那么在认为其中一只股票表现特殊之前,有必要先仔细考虑行业因素的影响。
<β系数在大多数情况下能够解释一只股票在日常交易中的表现。当然,也存在一些例外情况,但即便在这些特殊情况下,也需要通过进一步的分析才能判断这些异常现象是否具有实际意义。
<说了这么多,这篇文章也已经接近尾声了。希望你们能从中学到一些新的、有用的知识。非常感谢大家抽出时间阅读这篇文章。>