How to Analyze Insider Transactions with Python: A CEO Buying Case Study

TL;DR · AI 摘要
本文提供Python分析内部交易的完整流程,通过CEO购股案例验证事件研究方法,揭示数据处理与统计比较的关键步骤。
核心要点
- 使用EODHD API获取Form 4数据并处理重复交易行
- 构建事件研究需匹配CEO购股日与无购股对照组的回撤周期
- 统计显示CEO购股后60天平均回报率仅2.1%,未显著优于市场基准
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 内部交易事件研究
- 数据获取
- Form 4 API数据
- EODHD历史价格
- 处理流程
- 去重处理
- 事件窗口构建
- 对照组匹配
- 分析结果
- 回撤周期匹配
- 回报率统计
金句 / Highlights
值得收藏与分享的关键句。
CEO购股后60天平均回报率仅2.1%,未显著优于市场基准
需处理重复交易行、交易日对齐等5类数据清洗难题
使用EODHD API需提前申请并确保足够API配额
如何使用Python分析内部交易:CEO购股案例研究
2026年7月14日
/
Nikhil Adithyan
当CEO在公司股价大幅下跌后买入股票时,人们很容易将其解读为表达信心的举动。作为企业经营者,CEO比普通投资者了解更多信息,因此这笔交易看起来像是值得跟进的信号。
但这里存在一个显而易见的问题。股价下跌20%或更多时,即使没有内部人士买入,股票也经常会出现反弹。如果我们只衡量CEO买入后的表现,可能会错误地将股价复苏归因于内部人士的信号,而实际上这种复苏在跌势股票中本就常见。
在本教程中,我们将构建一个Python工作流程来正确验证这一点。我们将提取Form 4交易数据,筛选出CEO买入行为,将重复的申报行合并为可用事件,附加历史价格,计算回撤和未来回报,然后将买入事件与相同股票中无买入的日期进行对比。
有趣的部分不仅仅是最终的回报表格。更重要的是所有将杂乱的监管文件转化为可支持公平比较的数据集所需的过程。在过程中,我们将处理重复的交易行、同一CEO的多次购买、交易日对齐、不完整的股价历史以及一对一的控制匹配。
最终,我们将获得完整的事件研究工作流程,并得出比"CEO买入后股价上涨"更有价值的结论。
目录
- 前提条件
- 导入所需包
- 构建股票池
- 获取CEO买入数据并应用日期过滤
- 将Form 4行转换为每日买入事件
- 添加历史价格并计算回撤 计算历史高点并匹配回撤 将每次买入与最新可用价格匹配
- 将买入事件转换为事件集
- 计算CEO买入后的回报 按股票代码整理价格历史 找到入场日期并计算未来回报 汇总原始回报数据
- 构建无买入对照组 创建对照候选组 移除CEO买入附近的日期 将买入事件与对照组匹配 构建最终匹配数据集
- 对比CEO买入与相似无买入回撤 从任何信号日期计算未来回报 向两组应用相同的回报逻辑 构建最终对比结果
- 案例研究发现
- 该测试能说明和不能说明的内容
前提条件
您不需要具备高级金融或量化背景知识也能跟随本教程。基本的Python和pandas理解就足够了。
开始前,请确保您具备以下条件:
- 本地安装了Python,或有Jupyter Notebook、Google Colab等笔记本环境的访问权限
- 对数据框、函数、循环和API请求有基本了解
- 有EODHD API密钥,可访问筛选器、Form 4申报和历史EOD端点
- 有足够的API信用额度来处理您选择分析的股票数量
完整案例研究使用了500只证券的Form 4数据。如果您想在不使用太多API调用的情况下理解代码,可以先在较小的样本上运行该工作流程。
不需要事先了解事件研究或控制匹配的知识。我们将逐步构建这些部分,正如它们在分析中出现的那样。
导入所需包
import pandas as pd
import numpy as np
import requests
from datetime import datetime, timedelta
import matplotlib.pyplot as plt
import seaborn as sns我们只需要一小部分包即可完成完整的工作流程。requests处理API调用,pandas和NumPy完成大部分数据处理工作,SciPy提供了后续用于对照组的一对一匹配算法。
import json
import re
import numpy as np
import pandas as pd
import requests
from scipy.optimize import linear_sum_assignment这就是完整的环境配置。我们只导入分析实际需要的模块,没有添加额外的库或不必要的工具。在将这些包导入环境之前,请确保使用pip安装这些包。
构建股票池
在获取内幕交易文件之前,我们需要先建立要搜索的公司列表。
我们不会从单一市值区间开始,而是会构建一个包含微型股、小型股、中型股和大型股的混合股票池。这能让分析具有更多变异性,而不是让市场某一部分主导样本。
市值区间划分如下:
- micro_cap : 5000万美元至3亿美元
- small_cap : 3亿美元至20亿美元
- mid_cap : 20亿美元至100亿美元
- large_cap : 100亿美元及以上
对于每个区间,我们将获取500个筛选结果,随机选择250个,最终合并成包含1000只股票的股票池。
def fetch_stocks(filters, cap):
api_key = 'YOUR EODHD API KEY'
base_url = 'https://eodhd.com/api/screener'
all_stocks = []
for i in range(0,500,100):
params = {
"api_token": api_key,
"filters": json.dumps(filters),
"sort": "market_capitalization.desc",
"limit": 100,
"offset": i}
resp = requests.get(base_url, params = params).json()
stocks = list(pd.DataFrame(resp['data'])['code'])
all_stocks.append(stocks)
all_stocks = [item for sublist in all_stocks for item in sublist]
df = pd.DataFrame(columns = ['ticker', f'cap'])
df.ticker, df.cap = all_stocks, cap
df = df.sample(n = 250, random_state = 42)
return df
micro_filters = [
["exchange", "=", "us"],
["market_capitalization", ">=", 50_000_000],
["market_capitalization", "<", 300_000_000]
]
small_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 300_000_000],
["market_capitalization", "<", 2_000_000_000]
]
mid_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 2_000_000_000],
["market_capitalization", "<", 10_000_000_000]
]
large_filters = [
["exchange", "=", "NYSE"],
["market_capitalization", ">=", 10_000_000_000]
]
micro_stocks = fetch_stocks(micro_filters, 'micro_cap')
small_stocks = fetch_stocks(small_filters, 'small_cap')
mid_stocks = fetch_stocks(mid_filters, 'mid_cap')
large_stocks = fetch_stocks(large_filters, 'large_cap')
frames = [micro_stocks, small_stocks, mid_stocks, large_stocks]
stocks_1000 = pd.concat(frames, ignore_index = True)
stocks_1000 = stocks_1000.sample(frac = 1, random_state = 42).reset_index(drop = True)
stocks_1000注意:将YOUR EODHD API KEY替换为你的实际EODHD API密钥。如果没有密钥,可以注册EODHD开发者账号获取。
筛选器每次请求最多返回100行数据,因此循环分五批处理前500个结果。
然后我们从这些候选股票中随机抽取250个代码。固定的随机种子使选择过程可重复,重新运行该单元格会生成相同的样本。之后我们定义四个市值筛选条件,并对每个条件运行该函数。
最终的 DataFrame 包含 1,000 个股票代码,每个分类中各 250 个。
需要特别说明的一个注意事项是:微市值筛选器使用的是更广泛的美国交易所设置,而其他组别使用的是 NYSE。这是案例研究中使用的筛选样本,但不应将其视为对整个美国股市的完全代表性样本。
获取 CEO 购买记录并应用日期过滤
在准备好股票池后,我们可以使用 EODHD 的内幕交易(SEC Form 4)API 开始搜索 CEO 的购买记录。
Form 4 数据包含的不仅仅是简单的内幕购买信息。一份申报文件可能包含销售、奖励、期权行权、衍生品交易以及属于同一笔交易的多行记录。因此我们不能简单地下载所有申报文件并将每条记录视为买入信号。
对于本次分析,交易必须同时满足以下条件:
- 属于非衍生品交易
- 由高管申报
- 高管头衔明确标识为 CEO
- 使用交易代码 P
- 表示购入股份
- 股份数量和价格均为正数
- 涉及普通股
我们还会保留交易日期和申报日期。交易日期告诉我们 CEO 是何时买入股份的,而申报日期则告诉我们外部投资者何时能够观察到这笔购买。在后续分析中,申报日期将作为信号日期使用。
以下代码块处理完整的数据提取过程。它定义了过滤函数,对前 500 只股票执行该函数,并将所有符合条件的行合并到一个 DataFrame 中。
def fetch_ceo_purchases(ticker):
try:
api_key = 'YOUR EODHD API KEY'
all_form4 = []
for i in range(0,1000,100):
form4_url = f'https://eodhd.com/api/sec-filings/{ticker}/form4?api_token={api_key}&page[limit]=100&page[offset]={i}'
resp = requests.get(form4_url).json()['data']
all_form4.append(resp)
all_form4 = [item for sublist in all_form4 for item in sublist]
all_purchases = []
ceo_pattern = re.compile(
r'\bceo\b|chief executive officer|co-chief executive officer|co-ceo|chief exec officer',
re.IGNORECASE
)
for filing in all_form4:
footnote_map = {
footnote['footnote_id']: footnote['text']
for footnote in filing.get('footnotes', [])
}
for transaction in filing.get('non_derivative', []):
officer_title = transaction.get('officer_title') or ''
security_title = transaction.get('security_title') or ''
shares_amount = transaction.get('shares_amount')
price_per_share = transaction.get('price_per_share')
is_ceo = bool(ceo_pattern.search(officer_title))
is_purchase = (
transaction.get('is_officer') is True
and is_ceo
and transaction.get('transaction_code') == 'P'
and transaction.get('acquired_or_disposed') == 'A'
and shares_amount is not None
and shares_amount > 0
and price_per_share is not None
and price_per_share > 0
and 'common stock' in security_title.lower()
)
if not is_purchase:
continuelinked_footnotes = ' '.join( footnote_map.get(footnote_id, '') for footnote_id in transaction.get('footnote_ids', []) )
all_purchases.append({ 'ticker': ticker, 'accession_number': filing['accession_number'], 'filed_at': filing['filed_at'], 'transaction_date': transaction['transaction_date'], 'reporting_owner_cik': transaction['reporting_owner_cik'], 'reporting_owner_name': transaction['reporting_owner_name'], 'officer_title': officer_title, 'security_title': security_title, 'shares_amount': shares_amount, 'price_per_share': price_per_share, 'total_value': transaction.get('total_value'), 'shares_owned_after': transaction.get('shares_owned_after'), 'footnotes': linked_footnotes })
return all_purchases except: return None
all_ceo_purchases = []
for ticker in stocks_1000.ticker[:500]: ticker = ticker + '.US' ceo_purchases = fetch_ceo_purchases(ticker) if ceo_purchases: all_ceo_purchases.extend(ceo_purchases) print(f'{len(ceo_purchases)} ceo purchases found in {ticker}') else: print(f'no transaction found in {ticker}')
cp_df = pd.DataFrame(all_ceo_purchases) cp_df.to_csv('ceo_purchases.csv')
该函数以每批100条的形式请求文件,并将返回的页面数据合并为一个列表。随后会检查每条非衍生品交易是否符合CEO购买规则。
CEO头衔的验证使用了正则表达式,因为文件中并未采用完全一致的头衔格式。CEO可能以"CEO"、"Chief Executive Officer"或"Co-CEO"等形式出现,因此仅匹配单一字符串会导致有效记录被遗漏。
我们还保留了关联脚注信息。交易代码P具有参考价值,但单独使用无法完整说明情况。脚注可能揭示该交易涉及交易计划、发行活动或其他需要特别关注的安排。
我仅对宇宙中前500只证券执行了这一步骤,因为Form 4接口会快速消耗API配额。相同的循环也可以扩展到全部1000只股票以获取更大样本量。
收集完数据后,我们将数据集限定在2022年1月1日至2025年12月31日之间提交的文件。
cp_df = cp_df[cp_df["filed_at"].between("2022-01-01","2025-12-31")]
cp_df.tail()
这些仍然是原始申报记录,而非独立的CEO购买信号。当不同批次的股票以不同价格买入时,一次购买可能被拆分到多行记录中。下一步需要将这些碎片合并为每日购买事件。
## 将Form 4记录转换为每日购买事件
CEO可能在同一天以多个价格购买股票。申报文件可能将每个价格区间记录为独立行,尽管这些行属于同一次更大规模的购买。如果将每行都视为独立信号,那么交易频繁的购买日可能会因为被拆分为更多价格层级而获得不成比例的权重。
因此下一步需要将具有相同股票代码、CEO、申报文件和交易日期的行进行分组。
对每个分组,我们将汇总股票数量和总购买金额,并计算加权平均价格:
加权平均价格 = 总购买价值 / 总购买股数
以下代码块执行完整聚合操作,为每个每日CEO购买事件生成一行数据。
cp_df['purchase_value'] = ( cp_df['shares_amount'] * cp_df['price_per_share'] )
group_columns = [ 'ticker', 'reporting_owner_cik', 'reporting_owner_name', 'officer_title', 'accession_number', 'filed_at', 'transaction_date' ]
daily_events = ( cp_df.groupby( group_columns, as_index=False, dropna=False ) .agg( shares_purchased=('shares_amount', 'sum'), total_purchase_value=('purchase_value', 'sum'), transaction_rows=('shares_amount', 'size'), shares_owned_after=('shares_owned_after', 'max') ) )
daily_events['weighted_average_price'] = ( daily_events['total_purchase_value'] / daily_events['shares_purchased'] )
daily_events.filed_at = pd.to_datetime(daily_events.filed_at) daily_events.to_csv('ceo_purchases_grouped.csv') daily_events.tail()
purchase_value 列为每个原始数据行赋予聚合前的美元价值。当数据行完成分组后,这些值可以相加求和,而不会丢失不同购买价格的影响。
transaction_rows 列有助于检查数据压缩的程度。数值为1表示该日事件原本就只有一行数据。数值为5表示五个独立的申报行被合并为一个购买事件。
聚合操作将数据集从625条原始交易记录减少到535个每日购买事件。
这种差异不仅仅是数据整理工作。它将分析单位从"一个申报价格区块"转变为"一个CEO购买日",这更接近我们试图研究的经济事件。
尽管如此,我们仍无法计算回报率。购买事件只告诉我们CEO进行了购买,但无法说明股票是否接近高位、略有下跌,或已深度回调。接下来,我们将添加每个申报公开时可获取的价格背景信息。
## 添加历史价格和回撤数据
在本测试中,只有当我们知道股票当时的交易价格时,CEO的购买行为才变得有意义。
在年高5%下方进行的购买与股票下跌40%后的购买有着显著差异。因此,我们现在需要将历史价格数据引入工作流程,并衡量每只股票在申报公开时距离近期高点的跌幅。
我们将使用调整后收盘价而非原始收盘价,因为调整后价格考虑了股票拆分和股息等事件。这能为我们提供更一致的价格序列,便于跨时间比较。
我们从2021年至2025年获取价格数据,尽管购买分析从2022年开始。额外增加一年数据是因为2022年的首批观测值仍需要足够早的数据来计算一年期高点。
以下代码块从EODHD的历史EOD接口获取CEO购买数据集中所有股票的每日历史价格,并将它们合并到一个数据框中。
tickers = list(cp_df.ticker.unique()) historical_eod_entries = []
def fetch_historical_eod(ticker):
api_key = 'YOUR EODHD API KEY'
historical_url = f'https://eodhd.com/api/eod/{ticker}?from=2021-01-01&to=2025-12-31&period=d&api_token={api_key}&fmt=json'
historical_resp = requests.get(historical_url).json()
historical_filtered = []
for item in historical_resp:
item['ticker'] = ticker
keys = ['ticker', 'date', 'adjusted_close']
item = {key: item.get(key) for key in keys}
historical_filtered.append(item)
return historical_filtered
for ticker in tickers:
try:
historical_eod = fetch_historical_eod(ticker)
historical_eod_entries.extend(historical_eod)
print(f'{ticker} done')
except:
print(f'{ticker} error')此代码会获取历史数据,并为每个股票代码在每个交易日生成一行价格数据。接下来我们将计算滚动高点和回撤。
计算滚动高点和回撤
对于每个交易日,我们需要知道过去252个交易日(大约一年)内达到的最高调整后收盘价。
回撤的计算公式为:
回撤 = 调整后收盘价 / 252日滚动高点 - 1
-0.20的值表示股票价格较滚动高点下跌了20%,-0.35表示下跌了35%。
以下代码块将对每只股票的价格历史进行排序,计算滚动高点,并将结果转换为小数和百分比形式的回撤列。
historical_df = pd.DataFrame(historical_eod_entries)
historical_df['date'] = pd.to_datetime(historical_df.date)
historical_df['rolling_high_252'] = (historical_df.groupby('ticker')['adjusted_close'].transform
(lambda prices: prices.rolling(window=252, min_periods=200).max()))
historical_df['drawdown'] = (historical_df['adjusted_close']/ historical_df['rolling_high_252']- 1)
historical_df['drawdown_pct'] = (historical_df['drawdown'] * 100)关于min_periods=200参数需要简单说明:
完整的滚动窗口包含252个交易日,但如果强制要求恰好252个观测值,会删除很多早期数据行。允许在200个交易日后进行计算,既保持了灵活性,又确保有足够多的历史数据支持计算。
没有足够历史数据的行将保持缺失状态,而不是接受一个弱化的回撤估计值。
将每笔购买记录与最新可用价格匹配
现在需要将价格信息关联到每位CEO的购买事件中。
提交日期是信号日期,但我们不应该使用同一天的收盘价。Form 4表格可能在交易时段开始前、进行中或结束后提交,因此在文件出现时可能尚未知道当天的收盘价。
相反,我们使用严格早于提交日期的最新完成交易日的价格。
以下代码块通过merge_asof()实现这种匹配。与普通合并不同,它能将每份文件与最近的早期价格日期匹配,而不要求两个日期完全一致。
cp_df.filed_at = pd.to_datetime(cp_df.filed_at)
price_columns = ['ticker', 'date', 'adjusted_close', 'rolling_high_252', 'drawdown', 'drawdown_pct']
analysis_df = pd.merge_asof(
cp_df.sort_values(['filed_at', 'ticker']),
historical_df[price_columns].sort_values(['date', 'ticker']),
by='ticker',
left_on='filed_at',
right_on='date',
direction='backward',
allow_exact_matches=False
)
analysis_df = analysis_df.rename(columns={'date': 'price_date'})关键设置是 allow_exact_matches=False。该设置可防止3月10日的申报使用当天的收盘价,合并操作会改用3月10日之前最近的交易日数据。
合并后的数据框现在包含以下信息:
- price_date:用于匹配的交易日
- adjusted_close:该交易日的股票价格
- rolling_high_252:过去252个交易日的最高价
- drawdown:以小数形式表示的跌幅
- drawdown_pct:以百分比形式表示的相同跌幅
例如,drawdown_pct 值为-32.4表示在申报前最后一个交易日,该股票价格较过去252个交易日的最高价下跌了32.4%。
现在我们不仅知道CEO进行了股票购买,还清楚地了解到购买公告发布时股票的跌幅程度。
下一个问题是重复购买行为。一位CEO在几周内多次购买股票时,不应自动产生多个独立信号。
将购买事件转换为交易周期
目前数据集的结构是每个CEO购买日对应一行数据。这比直接处理原始Form 4数据行有所改进,但仍可能高估同一决策的权重。
设想一位CEO在周一购买股票,随后在下周再次购买,再在两周后第三次购买。从技术角度看,这是三个购买事件。但从经济角度看,这可能是一次持续的购买行动。
若将这三个事件视为独立信号,会给予频繁购买者比一次性完成相同决策的CEO更高的权重。因此在计算回报率前,我们需要将相邻的购买行为归类为购买周期。
规则很简单:同一CEO在同一只股票上的购买行为,当连续申报日期间隔不超过28个自然日时,属于同一个周期。
首先我们将没有可用回撤数据的购买事件移除,然后按股票代码、CEO和申报日期对剩余数据排序,并计算与前一次购买的间隔天数:
purchase_events = analysis_df.dropna(subset=["drawdown"])
purchase_events.filed_at = pd.to_datetime(purchase_events.filed_at)
purchase_events = purchase_events.sort_values(["ticker", "reporting_owner_cik", "filed_at"])
purchase_events["days_since_previous"] = purchase_events.groupby(["ticker", "reporting_owner_cik"])["filed_at"].diff().dt.days
purchase_events["new_episode"] = purchase_events["days_since_previous"].isna() | (purchase_events["days_since_previous"] > 28)
purchase_events["episode_id"] = purchase_events.groupby(["ticker", "reporting_owner_cik"])["new_episode"].cumsum()每个股票-CEO组合的首次购买会自动开启新周期,因为没有更早的申报可供比较。
之后只有当与前一次申报的间隔超过28天时,才会开启新周期。间隔28天或更短的购买行为将保留在同一周期内。
new_episode 的累计和为每个购买序列生成唯一标识符。
现在每个购买事件都归属到特定周期后,我们可以将事件合并为每个购买序列一行数据。
对每个周期,我们保留首次和最后一次申报日期,汇总购买股数和金额,统计购买次数,并保留首次申报日期的回撤数据。
episodes = purchase_events.groupby(["ticker", "reporting_owner_cik", "reporting_owner_name", "episode_id"],
as_index=False).agg(first_filing_date=("filed_at", "min"),
last_filing_date=("filed_at", "max"),
first_transaction_date=("transaction_date", "min"),
total_shares=("shares_purchased", "sum"),
total_purchase_value=("total_purchase_value", "sum"),
purchase_days=("filed_at", "nunique"),
transaction_events=("filed_at", "size"),
initial_drawdown=("drawdown", "first"),
initial_drawdown_pct=("drawdown_pct", "first"))这里有两种活动统计方式:
- purchase_days 统计该事件中不同的申报日期数量。
- transaction_events 统计被合并分组的每日购买事件数量。
总股份和购买价值覆盖整个事件周期。但回撤数据仅来自首次申报日期,因为这是信号开始的时刻。
这个细节对20%的过滤条件至关重要。
假设一个事件在股价较历史高点下跌18%时启动,随后在回撤达到24%后CEO再次买入。若称该事件始于20%下跌后会带来误导。
因此我们先进行分组,再使用initial_drawdown应用阈值过滤。
episodes_20 = episodes[episodes["initial_drawdown"] <= -0.20].copy()
print("所有购买事件:", len(episodes))
print("20%回撤事件:", len(episodes_20))
print("涉及的股票数量:", episodes_20["ticker"].nunique())
episodes_20现在我们将重复购买行为归并为137个独特的CEO购股事件,这些事件均发生在股价较最近252日高点下跌至少20%时。
这些事件就是我们将持续跟踪的实际信号。接下来,我们将在首次申报后的第一个交易日建仓,并测量此后1个月、3个月、6个月和12个月的收益表现。
计算CEO购股后的收益
我们现在有137个CEO购股事件,这些事件均发生在股价较最近252日高点下跌至少20%时。
下一个问题是显而易见的:这些申报公开后发生了什么?
我们将使用事件首次申报后的第一个交易日作为建仓日期。这使测试保持现实性。外部投资者无法在Form 4文件公开前采取行动,使用下一个交易日也解决了周末或节假日申报的处理问题。
收益计算周期为:
- 1个月:21个交易日
- 3个月:63个交易日
- 6个月:126个交易日
- 12个月:252个交易日
按股票代码整理价格历史数据
在计算收益前,我们需要为每只股票创建独立的按时间排序的价格序列。
这使收益计算函数能够找到正确的建仓日期,然后按固定交易日数向前推进,而无需反复过滤完整的历史数据框。
episodes_20 = episodes_20.reset_index(drop = True)
episodes_20['first_filing_date'] = pd.to_datetime(episodes_20['first_filing_date'])
historical_df['date'] = pd.to_datetime(historical_df['date'])
prices = historical_df[['ticker', 'date', 'adjusted_close']].dropna().sort_values(['ticker', 'date']) price_map = {ticker: group.reset_index(drop=True) for ticker, group in prices.groupby('ticker')}
price_map 是一个字典,每个股票代码对应其自身的日期和调整后收盘价数据框。
例如,price_map['AAT.US'] 仅包含 AAT.US 的历史价格数据,且已按从最早到最新的顺序排序。
### 确定买入日期并计算未来收益
现在我们可以编写一个函数来处理单次购买事件。
该函数将执行以下操作:
- 定位股票的价格历史数据
- 找到首次提交日期之后的第一个交易日
- 将该日的调整后收盘价作为买入价格
- 向前推算 21、63、126 和 252 个交易日
- 在每个时间窗口计算收益
def calculate_forward_returns(row): ticker_prices = price_map.get(row['ticker'])
if ticker_prices is None: return pd.Series(dtype='object')
dates = ticker_prices['date'].to_numpy(dtype='datetime64[ns]') entry_index = np.searchsorted(dates, np.datetime64(row['first_filing_date']), side='right')
if entry_index >= len(ticker_prices): return pd.Series(dtype='object')
entry_date = ticker_prices.loc[entry_index, 'date'] entry_price = ticker_prices.loc[entry_index, 'adjusted_close']
result = {'entry_date': entry_date, 'entry_price': entry_price} horizons = {'1m': 21, '3m': 63, '6m': 126, '12m': 252}
for label, days in horizons.items(): target_index = entry_index + days
if target_index < len(ticker_prices): target_price = ticker_prices.loc[target_index, 'adjusted_close'] result[f'date_{label}'] = ticker_prices.loc[target_index, 'date'] result[f'return_{label}'] = target_price / entry_price - 1 else: result[f'date_{label}'] = pd.NaT result[f'return_{label}'] = np.nan
return pd.Series(result)
forward_returns = episodes_20.apply(calculate_forward_returns, axis=1) episode_returns = pd.concat([episodes_20.reset_index(drop=True), forward_returns], axis=1)
episode_returns
生成的数据框包含事件详情、买入日期、买入价格以及每个时间窗口的未来收益:
### 汇总原始收益数据
查看单个事件很有用,但我们也需要一个完整的样本概览。
对于每个时间窗口,我们将计算:
- 可用观测值的数量
- 平均收益
- 中位数收益
- 收益为正的百分比
summary = []
for horizon in ['1m', '3m', '6m', '12m']: returns = episode_returns[f'return_{horizon}'].dropna()
summary.append({ 'horizon': horizon, 'observations': len(returns), 'mean_return': returns.mean(), 'median_return': returns.median(), 'positive_rate': (returns > 0).mean() })
summary_df = pd.DataFrame(summary) summary_df
初步来看,结果似乎很有希望。三个月后的平均收益达到 11.9%,十二个月后达到 35.4%。大多数十二个月的观测值也都是正收益。
但这也正是容易得出错误结论的地方。
这些数字告诉我们的是 CEO 购买之后发生了什么。它们没有告诉我们这些表现中有多少是来自于 CEO 的购买行为本身。
这些股票在购买时已经下跌了至少 20%。其中一些可能只是因为被低估的股票有时会反弹。
为了区分这两种效应,我们需要构建一个对照组,该对照组由相似回撤日期组成,且这些日期附近没有发生CEO购买行为。
## 构建无购买对照组
原始回报表看似令人鼓舞,但仍然将所有信用归于CEO的购买行为。
这并不是一个公平的测试。样本中的每只股票已经下跌了至少20%,而被重创的股票即使没有内幕交易也可能反弹。我们需要将每一起CEO购买事件与另一个日期进行比较,该日期同一只股票面临相似压力,但附近没有发生CEO购买。
有效的对照组必须满足六个规则:
- 相同股票代码
- 相同日历年
- 回撤幅度在5个百分点以内
- 不超过180个日历日
- 购买事件前后28天内无CEO购买
- 每个样本仅使用一次
每个CEO购买事件也仅匹配一次。
### 创建对照候选组
我们将从2022年至2025年期间,找出每只股票价格至少低于其过去252天最高价20%的每个交易日。
不过这里存在一个问题。股票可能在数月内持续低于该阈值。如果我们保留所有交易日,一次长期下跌可能生成数百个几乎相同的对照候选。
为避免这种情况,我们将每个连续回撤期划分为28天的区块,并从每个区块中保留一个候选。
hist = historical_df[['ticker', 'date', 'adjusted_close', 'rolling_high_252', 'drawdown', 'drawdown_pct']].dropna(subset=['drawdown'])
hist['date'] = pd.to_datetime(hist['date']) hist = hist[hist['date'].between('2022-01-01', '2025-12-31')].sort_values(['ticker', 'date'])
hist['below_20'] = hist['drawdown'] <= -0.20 hist['previous_below_20'] = hist.groupby('ticker')['below_20'].shift().fillna(False) hist['new_state'] = hist['below_20'].ne(hist['previous_below_20']) hist['drawdown_segment'] = hist.groupby('ticker')['new_state'].cumsum()
control_candidates = hist[hist['below_20']].copy()
control_candidates['segment_start'] = control_candidates.groupby(['ticker', 'drawdown_segment'])['date'].transform('min') control_candidates['anchor_block'] = ((control_candidates['date'] - control_candidates['segment_start']).dt.days // 28) control_candidates = control_candidates.sort_values(['ticker', 'date']).drop_duplicates(['ticker', 'drawdown_segment', 'anchor_block'])
control_candidates = control_candidates.reset_index(drop = True) control_candidates
below_20标记了通过回撤阈值的日期。
drawdown_segment则将连续下跌区间与其他区间区分开来。如果股票价格回升至阈值以上,之后再次跌破阈值,这将形成新的区间。
在每个区间内,anchor_block从回撤开始的日期计算28天窗口。每个窗口保留一行数据,这样可以得到一组可管理的日期,而不会将同一轮下跌中的每个交易日都视为独立事件。
这些日期只是潜在的对照组候选。我们仍需剔除那些接近CEO购买的日期。
### 剔除靠近CEO购买的日期
无购买对照组应真正与内幕信号分离。
例如,距离CEO申报日三天的回撤日期将是一个糟糕的对照组。交易可能已经发生,而申报可能尚未公开。
因此,我们收集事件数据集中所有CEO购买申报日期,而不仅仅是通过20%阈值的137起事件。
purchase_dates = analysis_df[['ticker', 'filed_at']].dropna().drop_duplicates() purchase_dates['filed_at'] = pd.to_datetime(purchase_dates['filed_at']) purchase_dates = purchase_dates.rename(columns={'filed_at': 'purchase_date'}) purchase_dates
现在,我们需要为每个候选日期找到其之前最近的购买申报和之后最近的购买申报。
两个 merge_asof() 操作可以完成此任务。第一个操作向后搜索,第二个操作向前搜索。
control_candidates = pd.merge_asof( control_candidates.sort_values('date'), purchase_dates.sort_values('purchase_date'), by='ticker', left_on='date', right_on='purchase_date', direction='backward' )
control_candidates = control_candidates.rename(columns={'purchase_date': 'previous_purchase_date'})
control_candidates = pd.merge_asof( control_candidates.sort_values('date'), purchase_dates.sort_values('purchase_date'), by='ticker', left_on='date', right_on='purchase_date', direction='forward' )
control_candidates = control_candidates.rename(columns={'purchase_date': 'next_purchase_date'})
现在每个候选日期都知晓其两侧最近的CEO购买日期。
我们可以计算这些申报之间的距离,并仅保留与两侧最近的CEO购买日期相隔超过28个日历日的日期。
days_from_previous = (control_candidates['date'] - control_candidates['previous_purchase_date']).dt.days days_to_next = (control_candidates['next_purchase_date'] - control_candidates['date']).dt.days
far_from_previous = control_candidates['previous_purchase_date'].isna() | (days_from_previous > 28) far_from_next = control_candidates['next_purchase_date'].isna() | (days_to_next > 28)
control_candidates = control_candidates[far_from_previous & far_from_next] control_candidates
缺少之前的或之后的申报是可以接受的。这仅仅意味着在候选日期的该侧数据集中没有对应的CEO购买记录。
此时,每个剩余行代表一个日期,满足以下条件:
- 股票价格下跌至少20%
- 该日期不属于任何CEO购买事件的邻近日期范围
- 股票具有足够的历史价格数据用于计算回撤
### 将购买事件与对照组匹配
现在进入实际的匹配过程。
我们首先为每个CEO购买事件和每个对照组候选对象分配唯一的标识符。同时提取日历年份,因为匹配必须来自同一股票和年份。
purchase_pool = episodes_20.reset_index(drop = True)
purchase_pool['first_filing_date'] = pd.to_datetime(purchase_pool['first_filing_date']) purchase_pool['year'] = purchase_pool['first_filing_date'].dt.year purchase_pool['purchase_id'] = np.arange(len(purchase_pool))
control_candidates['year'] = control_candidates['date'].dt.year control_candidates['control_id'] = np.arange(len(control_candidates))
匹配过程在每个股票-年份组内独立进行。
假设CEO在股票下跌32%时进行了购买,我们将在同一股票和年份中寻找一个无购买记录的日期,该日期的回撤接近32%,同时日期间隔不超过180个日历日。
只有满足以下条件时,配对才是有效的:
- 回撤差异 <= 0.05
- 日历间隔 <= 180 天
接下来的代码块构建可能的配对,并使用 linear_sum_assignment() 选择一对一的匹配集。
matches = [] max_drawdown_gap = 0.05 max_calendar_gap = 180
for (ticker, year), purchases in purchase_pool.groupby(['ticker', 'year']): controls = control_candidates[(control_candidates['ticker'] == ticker) & (control_candidates['year'] == year)].copy()
if controls.empty: continue
purchase_drawdowns = purchases['initial_drawdown'].to_numpy()[:, None] control_drawdowns = controls['drawdown'].to_numpy()[None, :] drawdown_cost = np.abs(purchase_drawdowns - control_drawdowns)
purchase_dates = purchases['first_filing_date'].to_numpy(dtype='datetime64[D]') control_dates = controls['date'].to_numpy(dtype='datetime64[D]') calendar_gap = np.abs((purchase_dates[:, None] - control_dates[None, :]).astype('timedelta64[D]').astype(int))
valid = (drawdown_cost <= max_drawdown_gap) & (calendar_gap <= max_calendar_gap)
if not valid.any(): continue
cost = drawdown_cost + calendar_gap / 1000000 cost[~valid] = 1000000
row_indices, column_indices = linear_sum_assignment(cost) keep = cost[row_indices, column_indices] < 1000000
selected = pd.DataFrame({ 'purchase_id': purchases.iloc[row_indices[keep]]['purchase_id'].to_numpy(), 'control_id': controls.iloc[column_indices[keep]]['control_id'].to_numpy(), 'drawdown_gap': drawdown_cost[row_indices[keep], column_indices[keep]], 'calendar_gap_days': calendar_gap[row_indices[keep], column_indices[keep]] })
matches.append(selected)
matched_pairs = pd.concat(matches, ignore_index=True)
核心思想实际上比代码初看时更简单。
drawdown_cost 衡量两个折价幅度之间的差距。例如,购买方的 -0.32 和对照组的 -0.34 之间的差异是 0.02,即两个百分点。
日历距离被作为非常小的次要因素加入。折价相似性仍然是首要考量,但当两个对照组几乎同样接近时,日期更接近的选项会被优先选择。
linear_sum_assignment() 防止同一个对照组被分配给多个购买事件。它寻找一组一对一匹配,使整个组的总成本最小化。
构建最终匹配数据集
目前的匹配结果仅包含购买ID、对照ID和距离度量值。
最后一步是将原始购买和对照详情重新合并到这些配对中,这样我们才能计算双方的收益。
selected_controls = control_candidates[['control_id', 'ticker', 'date', 'adjusted_close', 'drawdown', 'drawdown_pct']].rename(columns={'ticker': 'control_ticker',
'date': 'control_date',
'adjusted_close': 'control_signal_price',
'drawdown': 'control_drawdown',
'drawdown_pct': 'control_drawdown_pct'})
matched_sample = matched_pairs.merge(purchase_pool,on='purchase_id',how='left').merge(selected_controls,on='control_id',how='left')
matched_sample现在每一行都包含一个CEO购买事件及其匹配的无购买折价日期:
我们现在终于得到了最初想要的两个群体:在重大回撤后CEO的购买行为,以及在相同股票中发生类似回撤但附近没有CEO购买的情况。
将CEO购买与类似无购买回撤进行对比
这正是整个工作流程最终展现价值的地方。
matched_sample中的每一行都包含同一股票的两个日期:
- CEO购买事件的首次申报日期
- 无附近CEO购买的类似回撤日期
从这一步开始,两边必须完全一致地处理。CEO侧在申报日期后的第一个交易日建仓,对照组则在匹配回撤日期后的第一个交易日建仓。两者都使用相同的调整后价格和相同的收益周期。
我们首先准备两个信号日期列,并重建之前使用的股票级别价格映射。
matched_sample['first_filing_date'] = pd.to_datetime(matched_sample['first_filing_date'])
matched_sample['control_date'] = pd.to_datetime(matched_sample['control_date'])
historical_df['date'] = pd.to_datetime(historical_df['date'])
prices = historical_df[['ticker', 'date', 'adjusted_close']].dropna().sort_values(['ticker', 'date'])
price_map = {ticker: group.reset_index(drop=True) for ticker, group in prices.groupby('ticker')}价格映射为每个股票提供了有序的历史记录。这使我们能够使用同一个收益计算函数同时处理购买日期和对照日期,而无需为每个群体编写单独的逻辑。
从任意信号日期计算未来收益
下一个函数仅接受两个输入:股票代码和信号日期。
它会找到该日期之后的第一个交易日,使用调整后收盘价作为建仓价格,并计算21天、63天、126天和252天交易日后对应的收益。
def get_forward_returns(ticker, signal_date):
result = {
'entry_date': pd.NaT,
'entry_price': np.nan,
'return_1m': np.nan,
'return_3m': np.nan,
'return_6m': np.nan,
'return_12m': np.nan
}
ticker_prices = price_map.get(ticker)
if ticker_prices is None or pd.isna(signal_date):
return pd.Series(result)
dates = ticker_prices['date'].to_numpy(dtype='datetime64[ns]')
entry_index = np.searchsorted(dates, np.datetime64(signal_date), side='right')
if entry_index >= len(ticker_prices):
return pd.Series(result)
entry_price = ticker_prices.loc[entry_index, 'adjusted_close']
result['entry_date'] = ticker_prices.loc[entry_index, 'date']
result['entry_price'] = entry_price
for label, days in {'1m': 21, '3m': 63, '6m': 126, '12m': 252}.items():
target_index = entry_index + days
if target_index < len(ticker_prices):
target_price = ticker_prices.loc[target_index, 'adjusted_close']
result[f'return_{label}'] = target_price / entry_price - 1
return pd.Series(result)关键细节是side='right'参数。
它能防止任何一方在信号日期当天建仓。CEO购买的收益计算从申报日后开始,对照组的收益计算从匹配回撤日期后开始。
该函数为所有输出字段初始设置缺失值。如果某股票不可用或未来价格历史数据不足,对应周期的收益将保持为NaN。
对两个群体应用相同的收益计算逻辑
现在我们需要为每对匹配数据运行该函数两次。
首次处理使用CEO购买股票代码和申报日期,第二次处理使用控制组股票代码和控制日期。返回的列名添加了前缀,以便两种结果集保持易于区分。
purchase_returns = matched_sample.apply(lambda row: get_forward_returns(row['ticker'], row['first_filing_date']), axis=1).add_prefix('purchase_')
control_returns = matched_sample.apply(lambda row: get_forward_returns(row['control_ticker'], row['control_date']), axis=1).add_prefix('control_')
matched_returns = pd.concat([
matched_sample.reset_index(drop=True),
purchase_returns.reset_index(drop=True),
control_returns.reset_index(drop=True)], axis=1)生成的DataFrame现在将两种结果并排显示:
- CEO购买的交易日期和价格
- 控制组的交易日期和价格
- CEO购买的回报
- 控制组的回报
并非每对数据在所有时间窗口都能保留。只有当双方都具有足够的未来价格历史数据时,这对数据才可用。这就是为什么随着分析时间向12个月推进,观测值数量会下降的原因。
构建最终对比
最后一步是对比每个时间窗口下的两个回报序列。
我们将计算:
- 每个组的平均回报
- 每个组的中位数回报
- 匹配对内的平均回报差异
- 匹配对内的中位数回报差异
- 正回报率
- CEO购买组战胜控制组的配对占比
comparison = []
for horizon in ['1m', '3m', '6m', '12m']:
purchase_col = f'purchase_return_{horizon}'
control_col = f'control_return_{horizon}'
valid = matched_returns[[purchase_col, control_col]].dropna()
differences = valid[purchase_col] - valid[control_col]
comparison.append({
'horizon': horizon,
'matched_pairs': len(valid),
'purchase_mean': valid[purchase_col].mean(),
'control_mean': valid[control_col].mean(),
'mean_difference': differences.mean(),
'purchase_median': valid[purchase_col].median(),
'control_median': valid[control_col].median(),
'median_difference': differences.median(),
'purchase_positive_rate': (valid[purchase_col] > 0).mean(),
'control_positive_rate': (valid[control_col] > 0).mean(),
'purchase_win_rate': (valid[purchase_col] > valid[control_col]).mean()
})
comparison_df = pd.DataFrame(comparison)
comparison_df配对统计结果在此处具有重要意义。
median_difference 是以下值的中位数:
CEO购买回报 - 匹配控制组回报
针对每一对数据。它不是简单地用CEO组的中位数减去控制组的中位数。
胜率提出了更直接的问题:在多少比例的匹配对中,CEO购买事件实际上表现得更好?
一个月的结果较弱。CEO购买事件在平均值、中位数对差距、正回报率和胜率方面均落后于控制组。
三个月是唯一一个结果在所有指标上保持一致的时间窗口。CEO购买组平均多出6.2个百分点,中位数配对优势为3.9个百分点,且在59.6%的匹配中获胜。
六个月和十二个月的平均值看起来比典型配对更强。在十二个月时,CEO组平均多出近10个百分点,但仅在37.9%的对比中战胜控制组。
这种组合通常意味着少数大额赢家正在拉高平均值。
因此,最终的结论并不是CEO购买行为总是有效,或完全无关紧要。表面的优势在很大程度上取决于时间范围,而唯一所有指标指向相同方向的时期仅限于三个月窗口。
案例研究的发现
原始数据使CEO购买行为看起来总体积极。十二个月后,平均回报率达到35.4%,近三分之二的观测数据呈现正向收益。
但当我们加入匹配的无购买回撤数据后,结论变得更为有限。
- 一个月窗口未显示任何优势。CEO购买事件在均值、中位数对差距、正向回报率和胜率指标上均弱于对照组。
- 三个月是表现最强的窗口。CEO组平均回报率高出6.2个百分点,在59.6%的配对中战胜对照组。这是唯一所有主要指标指向一致方向的时间范围。
- 六个月和十二个月窗口的结论可靠性较低。尽管CEO购买组的平均值更高,但中位数对差距为负值,多数单个事件都输给了对照组。
- 回撤本身解释了大量现象。即使没有CEO购买,被压制的股票也经常反弹,因此原始购买后回报率高估了实际信号。
最可靠的结论并非CEO购买能预测长期复苏。在本样本中,更像是一个可能的三个月反转信号,且这一结果本身也应被视为探索性发现而非交易规则。
该测试的适用性与局限性
这是一个基于500只股票筛选的样本,而非完整市场。研究范围可能存在幸存者偏差,部分代码-P购买可能并非完全自主决策,匹配相似回撤并不能证明CEO购买导致了回报。这是一项探索性案例研究,而非交易策略。
该研究流程最有价值的部分是将内幕信号与被压制股票本身已有的行为区分开来。在加入对照组之前,CEO购买在多个时间窗口都显得总体积极。但加入对照组后,结果变得更为有限:可能仅存在三个月窗口的优势,但无法保证长期稳定的收益。
这可能不如证明CEO购买能预测复苏那样令人兴奋。但这是更准确的结论。该研究流程迫使我们用基准线检验自己想相信的故事,而基准线本身改变了最终结论。
一位致力于打造可访问金融分析工具的创业者,以及帮助金融科技公司扩大影响力和可见度的营销专家。目前正在构建两家公司:BacktestZone(一个无需编码即可回测技术交易策略的平台)和Scriptonomy(一家专注于金融科技公司的内容营销机构)。
如果本文对你有帮助,请分享它。
免费学习编程。freeCodeCamp的开源课程已帮助超过40,000人成为开发者。立即开始
ADVERTISEMENT