量化投资第三次作业——多因子选股策略

一、综述

本策略结合了多因子、$MACD$、止损策略的组合,其中多因子策略中考虑到了五个因子,分别是市值因子、流动性因子、杠杆因子、账面市值比因子、残差波动性因子,下文将会对这五个因子开展具体分析。最后在回测区间$2021.1.1-2021.6.30$,回测目标为申万行业、医疗服务,最后实现了$\pmb{132%}$的年化收益率

二、板块选择

在2023年,人们对医疗板块的持续关注不仅是疫情遗留下的影响,更是由于医疗技术的飞速发展和公众健康意识的提升所推动。从2021年疫情爆发开始,医疗板块便成为了市场的焦点。这段时间里,人们对医疗保健的需求显著增加,医疗行业的创新和发展受到了前所未有的重视。尽管疫情已经逐渐得到控制并走向结束,公众对医疗保健的兴趣和关注却并未随之减弱。事实上,这种关注已转化为对医疗行业长期稳定发展的期待。

在这种背景下,我们选择对申万行业的医疗板块进行回测交易,可以更好地理解并把握这一板块未来的发展趋势,为投资决策提供有力的数据支持。

三、因子分析

第一个因子:$Size$(市值因子)

市值的自然对数因子是指使用自然对数(基数为e)对公司市值进行转换所得到的值。

其中市值通常是指股票的总市值(股价乘以流通股数)。使用自然对数进行转换是为了减少原始数据的偏斜性和缩放差异,使得标的池中的数据更适合统计分析。

作用是降低极端值的影响,比如降低大市值公司对分析结果的影响,使数据更趋于正态分布;同时市值的自然对数因子常被视为一个衡量公司规模的指标,它可以帮助我们策略制定者区分大型公司和小型公司,并据此制定不同的投资策略,比如在模型中我们采用了降序,更加倾向于对大型公司进行投资;市值的自然对数因子也用于调整风险,因为公司规模较大可能意味着更低的相对风险。

小组在构建股票投资组合时,我们将市值的自然对数因子作为筛选或加权的依据,并且在多因子模型中,还把市值的自然对数与其他因子结合,共同决定股票的预期收益,我们更加偏好投资大型公司,所以我们采用降序。

第二个因子:$Liquidity$(流动性因子)

在模型中我们将流动性因子定义为过去一个月换手率之和的自然对数,具体计算公式如下:
$$
ln \sum_{i=1}^n \frac{V_t}{S_t}
$$
其中$V_t$是$t$日的交易量,$S_t$是 $t$ 日的流通股本。

它降低了极端值的影响。一般来说流动性越高,我们可以进行成本更低效率更高的买卖,而当股票的流动性降低,可能导致难以转手造成的风险加大时,我们迅速出售股票,寻找新的流动性好的优质股票。 在模型中我们将流动性因子作为参考,主要目的是为了平衡收益性和流动性,尤其是在我们进行短时间内大批量的股票交易策略时候流动性因子就更加重要。

第三个因子:$Leverage$(杠杆因子)

$杠杆因子=0.38市场杠杆+0.35资产负债比+0.27*账面杠杆$

我们从三个方面来考虑杠杆,第一个是市场杠杆,分析的是总债务占市场价值的比重,第二个是资产负债比,分析的是总负债占总资产的比重,第三个是账面杠杆,显示的是公司负债和股东权益的比率。三个杠杆的具体计算公式如下:

市场杠杆($mlev$):$me$是普通股数值,$pe$是优先股账面价值,$ld$是长期负债账面价值。
$$
mlev=\frac{me+pe+ld}{me}
$$

资产负债比($dtoa$):$td$是总负债账面价值,$ta$是总资产账面价值。
$$
dtoa=\frac{td}{ta}
$$
账面杠杆($blev$):$be$是普通股账面价值,$pe$是优先股账面价值,$ld$是长期负债账面价值.
$$
blev=\frac{be+pe+ld}{be}
$$

经过调试和报告,我们将权重分别设置为$0.38,0.35,0.27$,将更多的重心放在市场杠杆和资产负债比上来衡量财务风险,挑选出风险水平较低的股票。

第四个因子:$Book\ to\ price$(账面市值比因子)

上个季报公司公布的普通股权账面价值(即净资产)除以当前的市值。市值每月更新一次。净资产账面价值每季度更新一次。

从价值投资指标角度来讲,$B/M$高说明股票被低估了,此时可以考虑买入,$B/M$低则说明股票被高估了,此时可以考虑卖出。还有风险评估的角度,当$B/M$高的时候说明市场期望较低,下跌的空间较小,而$B/M$低说明市场期望过高了,下跌的风险大,尤其是在经济下行阶段,投资该种股票的风险更大。

在本模型中我们使用该因子来寻找被低估的股票,并且结合其他的因子指标进行价值判断。

第五个因子:$Residual\ Volatility$(残差波动因子)

残差波动因子衡量了收益率的加权波动性,更加具体的衡量了风险,它直接从股票自身的波动性来判断它的风险,帮助我们识别并且量化不能由市场因素来解释的风险。在本模型中我们通过减少高残差的股票来降低组合的整体风险。具体计算公式如下:

是过去30个交易日的日超额收益率波动率,按照指数加权权重,半衰期为$14$个交易日。
$$
dastd=\frac{1}{n}\sum_{t=1}^n w_t(r_{et}-r_e)^2
$$

三、因子筛选之后的策略:$MACD$策略和止损

在经过因子筛选之后,我们将五个因子筛选出来的股票进行交集并且置入$stock$列表中,再进行$MACD$策略进行判断,如果股票被高估则将股票移出列表,符合$MACD$信号则予以保留。

最后我们添加了止损策略,本策略的止损判断是当股票价值低于买入时价值的$70%$即触发止损,立刻卖出该股票。

四、历史回测结果对比

这是初始$demo$的表现:

在这之后我们增加了因子分析,将因子分析筛选出来的股票进行交易,发现策略年化收益率从$44%$提升到$63%$;阿尔法从$0.438$提升到$0.627$,相较于市场组合有一定优势;夏普比率从$1.397$提升到$1.477$,承担风险得到的额外补偿也进一步提升;但是最大回撤也从$0.219$提升到$0.247$,下行风险从$0.204$提升到$0.261$这说明收益提高的同时风险也有一定上升:

最后我们添加了$MACD$和止损策略,发现贝塔比率下降,虽然依然高于基础策略,但相较于因子策略,已经从$1.072$下降到$0.932$。夏普比率、阿尔法、策略年化收益率均显著提升,比如策略年化收益率从$63%$提升到$132%$;策略的波动率和下行风险均有降低,波动率从$0.361$降低到$0.355$,下行风险从$0.261$降低到$0.246$,说明在控制风险上起到了不错的效果:

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
from youngquant.api import *
from youngquant import exec_strategy
from yqdata.services.factor import *

import yqdata.services as yq
import pandas as pd
import numpy as np
import datetime
import statsmodels.api as sm
import warnings
import talib
warnings.filterwarnings('ignore')
config = {
"base": {
"start_date": "2021-01-01",
"end_date": "2021-06-30",
"frequency": "1d",
"benchmark": "000300.XSHG",
"accounts": {
"stock": 1000000
}
},
"extra": {
"log_level": "error",
},
"mod": {
"sys_analyser": {
"enabled": True,
"plot": True
},
"mongodb": {
"enabled": True,
}
}
}

def find_order_stocks(context, bar_dict):
'''
根据选股思路确定本月想下单的股票池,存入context.yq_order_stocks
'''
# 获取申万行业,医疗服务
df_tscodes = yq.concept.sw_detail(index_code='801156.SI')
ts_codes = df_tscodes['con_code']

# 获取过去某段时间的因子数据
factors_dict = {}
start_time = (context.now - datetime.timedelta(days=context.observation)).strftime("%Y%m%d")
end_time = context.now.strftime("%Y%m%d")
print('start_time = ',start_time)
print('end_time = ',end_time)
# 遍历股票
for ts_code in ts_codes:
# print(ts_code)
# 通过yq获取的股票代码后缀为SH、SZ,将其替换为XSHG、XSHE与youngquant中所需的后缀格式相统一(用于获取上市日期及判断是否停牌)
yq_code = ts_code.replace('SH', 'XSHG').replace('SZ', 'XSHE')
# 保证观测期内已上市并且没有停牌记录
if instruments(yq_code).days_from_listed() > context.observation and not any(is_suspended(yq_code, context.observation)):
# 最终选择的因子如下
# 市值因子:市值的自然对数
Size = yq.basic.daily_basic(ts_code=ts_code, start_date=start_time, end_date=end_time, fields='total_mv')
Log_Size = np.log(Size['total_mv'].mean())

#流动性因子: 一段时间内换手率之和的对数
query_body = {
"_source": ["ts_code", "turnover_rate_f", "trade_date"],
"query": {
"bool": {
"must": [
{"match_phrase": {"ts_code": ts_code}},
{"exists": {"field": "turnover_rate_f"}},
{"range": {"trade_date": {"gte": start_time, "lte": end_time}}}
]
}
},
"size": 10000
}
df = yq.common.query(index='daily_basic', body=query_body)
Liquidity = np.log(df["turnover_rate_f"].sum())

# 杠杆因子 =0.38*市场杠杆+0.35*资产负债比+0.27*账面杠杆
dff = get_finance_factor(ts_code=ts_code,start_date=start_time, end_date=end_time,factors='debt_to_asset_ratio_ttm,book_leverage_ttm,market_leverage_ttm')
if not dff.empty:
dff.fillna(0, inplace=True)
DTOA_mean = dff['debt_to_asset_ratio_ttm'].mean()
BLEV_mean = dff['book_leverage_ttm'].mean()
MLEV_mean = dff['market_leverage_ttm'].mean()
Leverage = 0.35*DTOA_mean+0.27*BLEV_mean+0.38*MLEV_mean
else:
Leverage = 0

# 账面市值比因子 = B/M = 1/pb
df_btop = yq.basic.daily_basic(ts_code=ts_code, start_date=start_time, end_date=end_time, fields='ts_code,pb')
df_btop['btop']= 1/df_btop['pb']
Book_to_price =df_btop['btop'].mean()


# 残差波动因子: 衡量收益率的加权波动性
#计算个股每日收益率
df = pd.DataFrame()
df_var = yq.basic.daily_basic(ts_code=ts_code, start_date=start_time, end_date=end_time, fields='close')
df['daily_return'] = df_var['close'].pct_change()
df = df.drop(df.index[0])

# 设置指数加权平均移动的半衰期
halflife = 12

# 计算指数加权平均移动
ewma = df['daily_return'].ewm(halflife=halflife).mean()

# 计算加权方差(收益波动率)
Residual_Volatility = np.sum(((df['daily_return'] - ewma) ** 2) * np.exp(-np.arange(len(df)) / halflife)) / np.sum(np.exp(-np.arange(len(df)) / halflife))

########################
factors_dict[ts_code] = {'Size':Log_Size,'Liquidity':Liquidity,'Book_to_price': Book_to_price,'Leverage': Leverage,'Volatility': Residual_Volatility}


factors_df = pd.DataFrame(factors_dict).T

# 按各个指标进行排名,可以封装为函数
factors_df['Size_rank'] = factors_df['Size'].rank(ascending=False)
factors_df['Leverage_rank'] = factors_df['Leverage'].rank()
factors_df['Liquidity_rank'] = factors_df['Liquidity'].rank(ascending=False)
factors_df['Book_to_price_rank'] = factors_df['Book_to_price'].rank(ascending=False)
factors_df['Volatility_rank'] = factors_df['Volatility'].rank(ascending=False)

# print('********')
#print(factors_df)
# 取各个指标排名前topM的股票的交集
topM, topN = context.topM, context.topN
alternative_stocks = factors_df.query('Volatility_rank < @topM & Size_rank < @topM & Book_to_price_rank < @topM & Liquidity_rank < @topM & Leverage_rank < @topM' )
# 取五个指标排名的均值,并据此重新进行排序
alternative_stocks['total_rank'] = alternative_stocks[['Size_rank', 'Book_to_price_rank', 'Liquidity_rank','Leverage_rank','Volatility_rank']].mean(axis=1).rank()

# 取排名前topN的股票,并将后缀转化为youngquant所需的后缀格式,便于后续买卖操作
order_stocks = alternative_stocks.query('total_rank < @topN').index
context.yq_order_stocks = [stock.replace('SH','XSHG').replace('SZ','XSHE') for stock in order_stocks]

#从前topN的股票中筛选掉被高估的股票
SHORTPERIOD = 12
LONGPERIOD = 26
SMOOTHPERIOD = 9
OBSERVATION = 200
for stock in context.yq_order_stocks:
pricefilter = history_bars(stock,OBSERVATION,'1d','close',adjust_type='pre')
macd, signal, hist = talib.MACD(pricefilter, SHORTPERIOD, LONGPERIOD, SMOOTHPERIOD)
if macd[-1] - signal[-1] < 0:
context.yq_order_stocks.remove(stock)

def sink_stock(context, bar_dict):
'''卖出本月未入选的股票,买入新入选的股票'''
# 确定卖出名单
stock_to_sell = set(context.portfolio.positions) - set(context.yq_order_stocks)
# 确定买入份额
target_percent = 1 / len(context.yq_order_stocks)
# 执行买卖操作
for stock in stock_to_sell:
order_target_percent(stock, 0)
# 避免出现次月实际选出股票数<上月,引起资金闲置
for stock in context.yq_order_stocks:
order_target_percent(stock, target_percent)

def price_in(context,bar_dict):
'''用上一交易日的收盘价近似本月第一个交易日的开盘价,即成交价'''
data0=[]
for stock in context.yq_order_stocks:
open0=history_bars(stock,1,'1d','close',adjust_type='pre')
data0.append(open0)
#保存数据以在止损函数中对比,确定止损标准
context.data0=data0

def stop_losing(context,bar_dict):
'''止损'''
for i in range(len(context.yq_order_stocks)):
lossdata=history_bars(context.yq_order_stocks[i],1,'1d','close',adjust_type='pre')
#当股票价格低于买入价的70%的时候止损
if lossdata < context.data0[i]*0.7:
order_target_percent(context.yq_order_stocks[i], 0)

def initialize(context):
context.observation = 30 # 观测期
context.topM = 200 # 每个指标选前M只股票
context.topN = 10 # 最终排名前N只股票
scheduler.run_monthly(find_order_stocks, tradingday=1, time_rule='before_trading')
scheduler.run_monthly(sink_stock, tradingday=1, time_rule=market_open(hour=0))
scheduler.run_monthly(price_in,tradingday=1)
scheduler.run_daily(stop_losing)

def handle_data(context, bar_dict):
pass

#results=exec_strategy(initialize=initialize, handle_data=handle_data, config=config)
exec_strategy(initialize=initialize, handle_data=handle_data, config=config)