策略来自《东方证券-股指期货趋势交易之蜘蛛网策略——从成交持仓表中捕捉知情投资者行为-高子剑、魏建榕》
结算会员成交持仓排名是每各交易日收盘后,交易所官网会公布的内容,披露信息为每个商品期货合约前20名结算会员的成交量与持仓量。
投资者的交易行为包含了对市场走势的预期,并最终反映到成交量和持仓量的变化之中。
将投资者分为两类:知情投资者和非知情投资者,前者在交易时更加坚定地偏向于卖或买地某一边,单位持仓量创造的成交量较小,后者交易行为较为反复、单位持仓量创造的成交量较大。
由于成交量、持买单量、持卖单量的前20名会员各不相同,挑出当月合约成交持仓表中3个排行榜共有的结算会员,个数记为m(m$<= 20 ) ,将当月合约剔除该 m 个结算会员后剩余的成交量、持买单量、持卖单量等效的视为一个会员单位,如此有 n = m + 1 个会员单位,针对该 n 个会员单位,成交量为 20),将当月合约剔除该m个结算会员后剩余的成交量、持买单量、持卖单量等效的视为一个会员单位,如此有n=m+1个会员单位,针对该n个会员单位,成交量为 20),将当月合约剔除该m个结算会员后剩余的成交量、持买单量、持卖单量等效的视为一个会员单位,如此有n=m+1个会员单位,针对该n个会员单位,成交量为V_i ,持买单量为 ,持买单量为 ,持买单量为b_i ,持卖单量为 ,持卖单量为 ,持卖单量为s_i ,构建统计量 ,构建统计量 ,构建统计量Stat_i=\frac{b_i+s_i}{V_i} ,越大说明其知情成分越高,针对前 20 名会员公司的总量构建统计量 ,越大说明其知情成分越高,针对前20名会员公司的总量构建统计量 ,越大说明其知情成分越高,针对前20名会员公司的总量构建统计量IF=\frac{OI}{Vol}$,其中OI为未平仓量(持买单量+持卖单量)、Vol为成交量。
构建ITS(知情投资者情绪,Informed Trader Sentiment)指标,计算n个会员单位的Stat,筛选Stat大于IF的会员单位,将筛选出的会员单位作为知情投资者,这些筛选出来的会员的持买单量之和为B、持卖单量之和为S, I T S = B − S B + S ITS=\frac{B-S}{B+S} ITS=B+SB−S。
构建UTS(非知情投资者情绪,Uninformed Trader Sentiment)指标,筛选Stat小于IF的会员单位,作为非知情投资者, U T S = B − S B + S UTS=\frac{B-S}{B+S} UTS=B+SB−S。
构建MSD(市场情绪差异,Market Sentiment Difference)指标, M S D ≡ I T S − U T S MSD\equiv ITS-UTS MSD≡ITS−UTS,描述了知情投资者与非知情投资者看多市场的力度差异。
策略:
本次基础数据需要整理的内容有四点:
商品期货交易所目前有四个:郑州商品交易所、上海期货交易所、大连商品交易所、广州期货交易所。由于广期所目前只有工业硅一个品种,不在我们统计范围内。
其它三个交易所,他们的数据各自有各自的一些特点,需要我们统一起来。
郑州商品交易所,19年以后给出了纯英文和中英结合的文件,前者给出了所有合约、后者只有主力合约,因为我们不确定每次用的是交易所给到的合约还是没给到的,因此我们筛选出后者:
# 筛选不含中文的文件
def contains_chinese(file_path):
return any(u'\u4e00' <= char <= u'\u9fff' for char in file_path)
if exchange == 'ZhenZhou':
futures_hold_folder_path_list = [path for path in futures_hold_folder_path_list if not contains_chinese(path)]
上期所和大商所有共同的问题就是没有时间,因此:
_, date_string = os.path.split(os.path.split(path)[0])
temp_df = pd.read_csv(path, encoding='gbk', skiprows=1)
temp_df['交易时间'] = pd.to_datetime(date_string)
上期所还有个问题是他的原始列名都是英文,因此需要改成中文:
if exchange == 'ShangHai':
temp_df.rename(columns={"INSTRUMENTID": "品类",
"PARTICIPANTABBR1": "会员简称_总成交量", "PARTICIPANTABBR2": "会员简称_总持买单量",
"PARTICIPANTABBR3": "会员简称_总持卖单量",
"CJ1": "成交量_总成交量", "CJ2": "成交量_总持买单量", "CJ3": "成交量_总持卖单量",
"CJ1_CHG": "增减_总成交量", "CJ2_CHG": "增减_总持买单量", "CJ3_CHG": "增减_总持卖单量", }, inplace=True)
因为原始数据是一日一日的,因此可以直接用groupby
的方法分类,这样就可以获取同一交易时间内的数据,对其寻找交集字符也就是筛选共有会员公司:
# 计算每日每个品种的交集字符
def find_intersection_chars(group):
member_cols = ['会员简称_总成交量', '会员简称_总持买单量', '会员简称_总持卖单量']
intersection = set(group[member_cols[0]])
for col in member_cols[1:]:
intersection = intersection.intersection(set(group[col]))
return list(intersection)
grouped = temp_df.groupby(["品类", "交易时间"])
for group_name, group_df in grouped:
intersection_chars = find_intersection_chars(group_df) # 计算每日每个品种的交集字符
原始数据都是str格式的,也就是上了千的数字都有逗号,这是第一点;其次里面有些合约不活跃导致前二十名会员公司不全,就会出现空值‘-’,这个空值不能加减,我们把他替换为0,但是这里面有数据是负数,相当于把其它的负值符号给消除了,也是个问题,我的解决方案是要求整个值完全相当于‘-’才做,否则不弄:
# 批量清理数据
def clean_and_convert(value):
value = 0 if pd.isna(value) or value == '-' else str(value).replace(',', '')
return int(float(value))
columns_to_clean = ['成交量_总成交量', '成交量_总持买单量', '成交量_总持卖单量', '增减_总成交量', '增减_总持买单量', '增减_总持卖单量']
group_df = group_df.fillna(0) # 使用fillna()方法将NaN值替换为0
group_df[columns_to_clean] = group_df[columns_to_clean].applymap(clean_and_convert) # 清理数据
这边代码多一些,我的思路是基础数据都在这里计算好,最后返回一个以具体合约、日期为索引的要求数据,即单合约、单日期下只有一行数据,比如MA305在2023年4月10日这一天的前二十名会员统计量等数据。
我这里是先把共有会员和非共有会员所需的数据计算出来,然后按研报的来,方法是创建一个字典然后循环验证是不是共有会员,是的话添加进去,然后分别计算统计量,和IF统计量进行比较分类,得到我们的需求数据。
# 基础数据
a = group_df[group_df['会员简称_总成交量'].isin(intersection_chars)]['成交量_总成交量'].sum()
d = group_df[~group_df['会员简称_总成交量'].isin(intersection_chars)]['成交量_总成交量'].sum()
# 衍生数据
IF = (h + j) / g
member_data = {member: {'成交量_总成交量': 0, '成交量_总持买单量': 0, '成交量_总持卖单量': 0} for member in intersection_chars} # 创建一个字典来存储交集会员的买单量、卖单量和成交量
for member in intersection_chars:
member_data[member]['成交量_总成交量'] = group_df.loc[group_df['会员简称_总成交量'] == member, '成交量_总成交量'].sum()
member_data[member]['成交量_总持买单量'] = group_df.loc[group_df['会员简称_总持买单量'] == member, '成交量_总持买单量'].sum()
member_data[member]['成交量_总持卖单量'] = group_df.loc[group_df['会员简称_总持卖单量'] == member, '成交量_总持卖单量'].sum()
member_stats = {member: (data['成交量_总持卖单量'] + data['成交量_总持买单量']) / data['成交量_总成交量'] for member, data in member_data.items()}
filtered_members = {"greater": [], "less": []}
for member, stats in member_stats.items():
if stats > IF:
filtered_members["greater"].append(member)
else:
filtered_members["less"].append(member)
# variable
exchange = 'ZhenZhou' # ZhenZhou, DaLian, ShangHai
def contains_chinese(file_path):
return any(u'\u4e00' <= char <= u'\u9fff' for char in file_path)
# 计算每日每个品种的交集字符
def find_intersection_chars(group):
member_cols = ['会员简称_总成交量', '会员简称_总持买单量', '会员简称_总持卖单量']
intersection = set(group[member_cols[0]])
for col in member_cols[1:]:
intersection = intersection.intersection(set(group[col]))
return list(intersection)
# 批量清理数据
def clean_and_convert(value):
value = 0 if pd.isna(value) or value == '-' else str(value).replace(',', '')
return int(float(value))
# read data
futures_hold_folder_path = r'\exchange_%s\*' % exchange
futures_hold_folder_path_list = glob(os.path.join(futures_hold_folder_path, '*'))
if exchange == 'ZhenZhou':
futures_hold_folder_path_list = [path for path in futures_hold_folder_path_list if not contains_chinese(path)]
futures_hold_folder_path_list = futures_hold_folder_path_list[2830:]
elif exchange == 'DaLian':
futures_hold_folder_path_list = futures_hold_folder_path_list[15830:] # 过滤前面不需要的时间
elif exchange == 'ShangHai':
futures_hold_folder_path_list = futures_hold_folder_path_list[20830:] # 过滤前面不需要的时间
result = []
for path in tqdm.tqdm(futures_hold_folder_path_list):
if exchange == 'ZhenZhou':
temp_df = pd.read_csv(path, encoding='gbk', parse_dates=['交易时间'], skiprows=1)
grouped = temp_df.groupby(["品类", "交易时间"])
else:
_, date_string = os.path.split(os.path.split(path)[0])
temp_df = pd.read_csv(path, encoding='gbk', skiprows=1)
temp_df['交易时间'] = pd.to_datetime(date_string)
if exchange == 'ShangHai':
temp_df.rename(columns={"INSTRUMENTID": "品类",
"PARTICIPANTABBR1": "会员简称_总成交量", "PARTICIPANTABBR2": "会员简称_总持买单量",
"PARTICIPANTABBR3": "会员简称_总持卖单量",
"CJ1": "成交量_总成交量", "CJ2": "成交量_总持买单量", "CJ3": "成交量_总持卖单量",
"CJ1_CHG": "增减_总成交量", "CJ2_CHG": "增减_总持买单量", "CJ3_CHG": "增减_总持卖单量", }, inplace=True)
else:
temp_df.rename(columns={"合约代码": "品类",}, inplace=True)
grouped = temp_df.groupby(["品类", "交易时间"])
for group_name, group_df in grouped:
intersection_chars = find_intersection_chars(group_df) # 计算每日每个品种的交集字符
columns_to_clean = ['成交量_总成交量', '成交量_总持买单量', '成交量_总持卖单量', '增减_总成交量', '增减_总持买单量', '增减_总持卖单量']
group_df = group_df.fillna(0) # 使用fillna()方法将NaN值替换为0
group_df[columns_to_clean] = group_df[columns_to_clean].applymap(clean_and_convert) # 清理数据
# 基础数据
a = group_df[group_df['会员简称_总成交量'].isin(intersection_chars)]['成交量_总成交量'].sum()
b = group_df[group_df['会员简称_总持买单量'].isin(intersection_chars)]['成交量_总持买单量'].sum()
c = group_df[group_df['会员简称_总持卖单量'].isin(intersection_chars)]['成交量_总持卖单量'].sum()
d = group_df[~group_df['会员简称_总成交量'].isin(intersection_chars)]['成交量_总成交量'].sum()
e = group_df[~group_df['会员简称_总持买单量'].isin(intersection_chars)]['成交量_总持买单量'].sum()
f = group_df[~group_df['会员简称_总持卖单量'].isin(intersection_chars)]['成交量_总持卖单量'].sum()
g = group_df['成交量_总成交量'].sum()
h = group_df['成交量_总持买单量'].sum()
j = group_df['成交量_总持卖单量'].sum()
# 衍生数据
IF = (h + j) / g
member_data = {member: {'成交量_总成交量': 0, '成交量_总持买单量': 0, '成交量_总持卖单量': 0} for member in intersection_chars} # 创建一个字典来存储交集会员的买单量、卖单量和成交量
for member in intersection_chars:
member_data[member]['成交量_总成交量'] = group_df.loc[group_df['会员简称_总成交量'] == member, '成交量_总成交量'].sum()
member_data[member]['成交量_总持买单量'] = group_df.loc[group_df['会员简称_总持买单量'] == member, '成交量_总持买单量'].sum()
member_data[member]['成交量_总持卖单量'] = group_df.loc[group_df['会员简称_总持卖单量'] == member, '成交量_总持卖单量'].sum()
member_stats = {member: (data['成交量_总持卖单量'] + data['成交量_总持买单量']) / data['成交量_总成交量'] for member, data in member_data.items()}
filtered_members = {"greater": [], "less": []}
for member, stats in member_stats.items():
if stats > IF:
filtered_members["greater"].append(member)
else:
filtered_members["less"].append(member)
filtered_members_total_buy = {
"greater": sum(member_data[member]['成交量_总持买单量'] for member in filtered_members["greater"]),
"less": sum(member_data[member]['成交量_总持买单量'] for member in filtered_members["less"])
}
filtered_members_total_sell = {
"greater": sum(member_data[member]['成交量_总持卖单量'] for member in filtered_members["greater"]),
"less": sum(member_data[member]['成交量_总持卖单量'] for member in filtered_members["less"])
}
filtered_members_total_volume = {
"greater": sum(member_data[member]['成交量_总成交量'] for member in filtered_members["greater"]),
"less": sum(member_data[member]['成交量_总成交量'] for member in filtered_members["less"])
}
# result
result_dict = {
"品类": group_name[0],
"交易时间": group_name[1],
"交集字符": intersection_chars,
"共有会员_总成交量": a,
"共有会员_总持买单量": b,
"共有会员_总持卖单量": c,
"非共有会员_总成交量": d,
"非共有会员_总持买单量": e,
"非共有会员_总持卖单量": f,
"前20会员总成交量": g,
"前20会员总持买单量": h,
"前20会员总持卖单量": j,
'IF': IF,
'ITS_buy': filtered_members_total_buy['greater'], 'ITS_sell': filtered_members_total_sell['greater'], 'ITS_volume': filtered_members_total_volume['greater'], 'UTS_buy': filtered_members_total_buy['less'], 'UTS_sell': filtered_members_total_sell['less'], 'UTS_volume': filtered_members_total_volume['less']
}
result.append(result_dict) # 用字典的形式添加,效率更高
result_df = pd.DataFrame(result)
result = result_df.sort_values(['交易时间', '品类'])
print(result)
result.to_csv(r'exchange/%s_hold.csv' % (exchange), encoding='GBK')
研报中只计算了共有会员中,统计量大于前二十名会员的作为知情投资者,共有会员中低于统计量的作为非知情投资者,但这个非知情投资者或许本身更多倾向于知情投资者中的弱势知情投资者,然后共有会员与非共有会员类似于大体量公司和小体量公司的对比,小体量公司也会有大户存在,或许反映了一些特殊情况。
因此我把共有会员和非共有会员按照研报的知情与非知情又构建了一次指标:
def cal_spider_holding(df):
df['知情投资者情绪_共有会员'] = (df['共有会员_总持买单量'] - df['共有会员_总持卖单量']) / (df['共有会员_总持买单量'] + df['共有会员_总持卖单量']) # ITS,类似
df['非知情投资者情绪_非共有会员'] = (df['非共有会员_总持买单量'] - df['非共有会员_总持卖单量']) / (df['非共有会员_总持买单量'] + df['非共有会员_总持卖单量']) # UTS
df['ITS_Stat'] = (df['ITS_buy'] + df['ITS_sell']) / df['ITS_volume']
df['UTS_Stat'] = (df['UTS_buy'] + df['UTS_sell']) / df['UTS_volume']
df['ITS'] = (df['ITS_buy'] - df['ITS_sell']) / (df['ITS_buy'] + df['ITS_sell'])
df['UTS'] = (df['UTS_buy'] - df['UTS_sell']) / (df['UTS_buy'] + df['UTS_sell'])
df['市场情绪差异_自创'] = df['知情投资者情绪_共有会员'] - df['非知情投资者情绪_非共有会员'] # MSD
df['MSD'] = df['ITS'] - df['UTS'] # MSD
df.fillna(method='ffill', inplace=True)
return df
此外,我把研报中的隔日持仓变为了趋势持仓,即不出现相反信号的话就一直拿着之前的单子。
持仓因子可以展示哪些品种资金影响程度大、哪些品种资金影响程度小,以及哪些品种资金是正向指引、哪些品种资金是负向指引,甚至可以进一步探究哪些品种的基本面与资金预期经常撇叉等。因此持仓因子也可以当作新因子加入量化体系,目前各因子是等权重的,后续可以根据IC等方式自动调整权重。
回测时间为2018年1月1日至2023年3月7日。
以下,1为正做、-1为反做,比如1时,ITS>0做多、<0做空,如果是-1则ITS<0做空、>0做多。
先更精确理解一下各定义,首先ITS、UTS、知情投资者情绪、非知情投资者情绪的统计量构建是没有差别的,都是对应的持买单量与持卖单量相减除以两者相加,其次市场情绪差异和MSD的构建是一致的,都是对应的知情减非知情。
然后更精确理解一下各分类:
首先,整体来看效果不好,只是个别品种存在一定效果,而且每个品种表现好的策略不一样,比如MA在ITS反做效果好、但FG在ITS正做效果好,如此该策略就没有普适性,更多需要去思考单体品种效果好的背后逻辑。
以下是年化收益在15%以上的策略与品种:
para | 年化收益 | 最大回撤 | 年化收益回撤比 | symbol |
---|---|---|---|---|
[‘ITS’, 1] | 0.39 | -0.25 | 1.54 | FG |
[‘知情投资者情绪_共有会员’, -1] | 0.38 | -0.42 | 0.91 | MA |
[‘市场情绪差异_自创’, -1] | 0.27 | -0.50 | 0.54 | TA |
[‘ITS’, -1] | 0.27 | -0.50 | 0.53 | MA |
[‘非知情投资者情绪_非共有会员’, -1] | 0.23 | -0.21 | 1.09 | RM |
[‘MSD’, 1] | 0.21 | -0.28 | 0.77 | ru |
[‘MSD’, 1] | 0.21 | -0.16 | 1.25 | PK |
[‘UTS’, 1] | 0.20 | -0.28 | 0.72 | lu |
[‘ITS’, -1] | 0.20 | -0.32 | 0.60 | p |
[‘非知情投资者情绪_非共有会员’, 1] | 0.20 | -0.62 | 0.32 | TA |
[‘UTS’, -1] | 0.19 | -0.25 | 0.76 | pg |
[‘知情投资者情绪_共有会员’, 1] | 0.19 | -0.31 | 0.62 | FG |
[‘ITS’, -1] | 0.19 | -0.34 | 0.55 | SA |
[‘市场情绪差异_自创’, 1] | 0.18 | -0.42 | 0.44 | FG |
[‘MSD’, -1] | 0.18 | -0.46 | 0.39 | MA |
[‘非知情投资者情绪_非共有会员’, -1] | 0.18 | -0.34 | 0.52 | SA |
[‘ITS’, -1] | 0.17 | -0.60 | 0.28 | TA |
[‘UTS’, 1] | 0.17 | -0.50 | 0.34 | ru |
[‘知情投资者情绪_共有会员’, -1] | 0.16 | -0.29 | 0.56 | OI |
[‘非知情投资者情绪_非共有会员’, 1] | 0.16 | -0.18 | 0.89 | lh |
[‘ITS’, -1] | 0.16 | -0.27 | 0.57 | OI |
[‘MSD’, 1] | 0.15 | -0.20 | 0.77 | l |
正做即>参数时做多、<参数时做空。
根据表格,第一,整体效果确实优于无参数策略;第二,可以明显发现收益的参数呈现左偏状态,说明和研报里针对股指期货得到的结论相同,同时该情况在历经10年以后依然在商品期货中表现;第三,不论是大体量公司,还是偶尔上榜的小体量公司之和,或是研报划定的知情投资者、非知情投资者,全都按他们的净持仓反着做效果更好,即跟市场大部分人反着做能赚钱(参数左偏的体现)。
参数左偏的原因研报里也已经解释,前二十大会员通常净持仓为空头、由于机构套期保值的需求;但左偏的程度是大于研报里的股指期货的,回测了更细致的参数,研报中的最佳参数-0.06在商品这里的平均回撤比就排到后面了,商品的-0.6效果相对更好,或许代表商品期货中产业套保力量的强大。
以下是年化收益在20%以上的策略与品种,可以看到各品种集中度较高,即某品种在不同策略的表现趋同:
para | 年化收益 | 最大回撤 | 年化收益回撤比 | symbol |
---|---|---|---|---|
[‘MSD’, -0.3] | 0.43 | -0.21 | 2.03 | pg |
[‘ITS’, 0] | 0.39 | -0.25 | 1.54 | FG |
[‘市场情绪差异_自创’, -0.3] | 0.27 | -0.40 | 0.67 | UR |
[‘ITS’, -0.3] | 0.25 | -0.16 | 1.60 | lu |
[‘ITS’, -0.6] | 0.25 | -0.40 | 0.64 | UR |
[‘知情投资者情绪_共有会员’, -0.6] | 0.25 | -0.40 | 0.63 | UR |
[‘UTS’, -0.6] | 0.25 | -0.40 | 0.63 | UR |
[‘市场情绪差异_自创’, -0.6] | 0.25 | -0.40 | 0.63 | UR |
[‘非知情投资者情绪_非共有会员’, -0.6] | 0.25 | -0.40 | 0.63 | UR |
[‘MSD’, -0.3] | 0.24 | -0.28 | 0.84 | PF |
[‘UTS’, -0.3] | 0.24 | -0.41 | 0.57 | UR |
[‘UTS’, -0.3] | 0.23 | -0.26 | 0.89 | pg |
[‘非知情投资者情绪_非共有会员’, -0.3] | 0.23 | -0.43 | 0.53 | UR |
[‘ITS’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘知情投资者情绪_共有会员’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘UTS’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘知情投资者情绪_共有会员’, -0.3] | 0.22 | -0.40 | 0.54 | i |
[‘非知情投资者情绪_非共有会员’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘市场情绪差异_自创’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘MSD’, -0.6] | 0.22 | -0.40 | 0.54 | i |
[‘ITS’, -0.3] | 0.22 | -0.39 | 0.55 | i |
[‘UTS’, -0.3] | 0.21 | -0.40 | 0.53 | i |
[‘MSD’, 0] | 0.21 | -0.28 | 0.77 | ru |
[‘市场情绪差异_自创’, -0.3] | 0.21 | -0.22 | 0.95 | eb |
[‘非知情投资者情绪_非共有会员’, -0.3] | 0.21 | -0.40 | 0.52 | i |
[‘市场情绪差异_自创’, -0.6] | 0.21 | -0.29 | 0.73 | OI |
[‘MSD’, 0] | 0.21 | -0.16 | 1.25 | PK |
反做即>参数时做空、<参数时做多。
可以明显发现收益的参数呈现右偏状态,和上个正做策略得到的结论一致。
对MSD进行均线化,具体策略为:MSD上穿过去N日MSD均线时做多、反之做空。
参数平原不是特别有规律,3日均线,有着不错的收益。
但整体看效果并没有相对前述策略质的提升。