策略好不好,测了才知道 —— 但对于转债来说,可能没那么容易。
虽然转债已经基本告别当年迷你市场的窘境,但依然是一个小市场,这是我们在年度回顾报告中的一个判断 —— 而附带的一个影响是,很多配套的东西还不完备,比如策略测试的代码框架。幸好开源的理念之下,这些事情自行处理也并不太复杂。我们在此介绍一个简单的测试框架及其Python实现方法。
首先还是看大的框架,然后再一步一步完成细化实现。
大体的流程应该包括:
1、初始化定义:测试的时间段、考虑的转债的范围(比如含不含EB、含不含那些因股改而退市的品种)、调仓周期、以及最终的返回值——策略的净值和必要的记录;
2、进入测试循环:计算净值、并在调仓的时点上进行调仓;
3、返回结果。此时的Python代码如下:
#
引入三个必须引用的库
import
datetime
as
dt
import
pandas
as
pd
import
numpy
as
np
def
frameStrategy
(obj, start=
'2015/12/31'
):
'''
这里的参数还不完全,为了简单先只留最简单的两个
obj
是我们自己设定的一个
class
,进行日常的转债数据维护和计算,不过此时投资者不必太过在意,因为后面我们将只用其作为数据库的功能
obj.DB
例如
obj.DB[
'Amt'
]
将返回一个记录转债成交额的
pd.DataFrame
,
index
是
yyyy/mm/dd
型的日期,
columns
是各转债的代码
'''
#
设定起始日期在库中的位置(我们的数据从
2002
年开始,这里要返回一个整数,记录
start
在其中的位置,比如
2015/12/31
对应的是
3391
)
#
这个
getStartLoc
将在后面介绍,后面还有很多这类函数
intStart =
getStartLoc
(obj, start)
# dfRet
是最终要返回的表,
'
NAV'
这一列就是最重要的了:策略净值(我们这里是
100
为起点)
dfRet = pd.
DataFrame
(index=obj.D
B[
'Amt'
].index[intStart:],columns=[
'NAV'
,
'LOG
:
SEL'
,
'LOG:WEIGHT'
])
#
这个表记录了持仓,
index
是转债代码,初始先设定成
[Nothing]
dfAssetBook = pd.
DataFrame
(index=[
'Nothing'
],columns=[
'costPrice'
,
'w'
])
#
需要一个变量来记录持仓中的现金(或者借款)
cash = 100.0
#
设定转债代码范围
codes =
defineCodes
(obj, defineMethod)
#
一个调仓的日期列表,这里设定的是每
21
个交易日调仓一次
isAdjustDate =
roundOfAdjust
(obj, start, 21)
#
进入循环,
enumerate
是
python
里面一个很好用的迭代函数
for
i,date
in
enumerate
(dfRet.index):
#
这一步来记录净值变化
checkBook
(obj, dfRet, dfAssetBook, cash,date)
#
判定当日是否需要调仓
if
date
in
isAdjustDate:
#
如果需要调仓,进入
selectCodes
函数,根据策略选择个券
sel =
selectCodes
(obj, codes, date, selMethod)
if
sel:
#
这一步得到权重变量
w =
getWeight
(obj, sel, date, weightMethod)
else
:
sel = [
'Nothing'
]
w = 0.0
dfAssetBook = pd.
DataFrame
(index=sel, columns=[
'costPrice'
,
'w'
])
dfAssetBook[
'costPrice'
] = 100.0
dfAssetBook[
'w'
] = w
#
无论如何,都用
dfRet
来记录当日持仓的个券和权重
# join
函数非常实用,用来连接字符串
dfRet[
'LOG
:
SEL'
][date] =','.
join
(
list
(dfAssetBook.index))
# [
func
(t)
for
t
in
...]
是非常具备
python
特色的一个处理方法
dfRet[
'LOG
:
WEIGHT'
][date] =','.
join
([
str
(t)
for
t
in
list
(dfAssetBook[
'w'
])])
return
dfRet
下面来逐个击破中间的小函数。
首先是getStartLoc,实际上pd.DataFrame的index有一个get_loc的方法也能得到这个结果,但早期的版本没考虑过万一要找的变量不在index中怎么办。而后来的版本中,虽然给予了一定容忍度,但也基本没考虑过当index本身是不可比变量时的处理。所以此时我们要进行简单的改造,如下:
def
getStartLoc
(obj,date):
#
如果
get_loc
能解决,就交给它吧
if
date
in
obj.DB[
'Amt'
].index:
i= obj.DB[
'Amt'
].index.
get_loc
(date)
else
:
#
如果解决不了,要先把
index
转化成
datetime
型,而非原本的字符型,这样
get_loc
就能万用了
fakeIndex = obj.DB[
'Amt'
].index.
map
(str2dt)
i= fakeIndex.
get_loc
(
str2dt
(date),method=
'ffill'
)
return
i
接下来是定义个券大致范围的defineCodes:
一般要剔除因股改而退市的那些转债,有时候我们也希望剔除EB。投资者也可以设定其他的规则,这就需要用到一个python特性:函数可以作为参数传入另一个函数。这样的话,投资者可以自行编写一个函数,作为定义范围的方法。实现如下:
def
defineCodes
(obj,method=
'default'
):
if
method==
'default'
:
return
obj._
excludeSpecial
()
elif
method==
'nonEB'
:
return
obj._
excludeSpecial
(hasEB=0)
elif hasatrr(method,’__call__’)
:
#
这一句是判断
method
是不是一个函数,如果是,则调用这个函数
return
method(obj)
# _
excludeSpecial
()
是我们的
obj
中的方法,如下:
def _
excludeSpecial
(self,hasEB=1):
columns =
set
(
list
(self.DB[
'Amt'
].columns))
#
这个
cb_data.lstSpecial
里面存了那些因股改而退市的转债的代码
columns -=
set
(cb_data.lstSpecial)
columns =
list
(columns)
#
如果不要
EB
,进入下面的程序
if
not hasEB:
for
code
in
columns:
if
code[:3] ==
'132'
or code[:3] ==
'120'
:
columns.
remove
(code)
return
columns
下面是择券的代码,也是对策略决定意义最大的函数。
在调仓日期会调用这个函数。同样,为了给予投资者外部接口,这里也要保留传入函数的可能性。如下:
def
selectCodes
(obj, codes, date,selMethod=None):
i =
getStartLoc
(obj,date)
n =
min
([i,5])
#
这里利用一下
pandas.DataFrame
的逻辑运算做最基本的条件设定:前
5
个交易日必须有最少
10
万的交易
#
且存量不低于
3000
万
condition = (obj.DB[
'Amt'
].iloc[i-n:i][codes].
fillna
(0).
min
() >100000.0) & \
(obj.DB[
'Outstanding'
].iloc[i][codes]> 30000000.0)
##
如果
selMethod
不为空
If
selMethod:
tempCodes=
list
(condition[condition].index)
moreCon= selMethod(obj, codes, date, tempCodes)
condition&= moreCon
#
这个函数最后返回的变量是这个
retCodes =
list
(condition[condition].index)
#
如果一个都没有,进入这里,并给出提示
if
not retCodes:
print
'its a empty selection, when date: '
,date
return
retCodes
#
下面以低价策略举例,如果我们希望在调仓时买入所有价格低于均价的品种,则可以写下面这个函数,并把
_lowprice
作为
selMethod
传入上面的函数:
def
_
lowPrice
(obj, codes, date, tempCodes):
avgPrice = obj.DB['Close'].loc[date][tempCodes].mean()
return
obj
.DB['Close'].loc[date, codes] <= avgPrice
然后是转债初始权重的设定函数:
我们可以预设几个常用的,比如等权、市值加权。投资者也可以自行设定,自然这也要依赖于传入一个函数参数,不过在加权这个上面,往往不用太多费精力:
def
getWeight
(obj, codes, date, method=
'average'
):
if method ==
'average'
:
#
等权策略
#
这里要依赖一下
numpy
中的
ones
了
ret = pd.
Series
(np.ones(len(codes))/
float
(
len
(codes)),index=codes)
return
ret
elif method ==
'fakeEv'
:
#
按发行额加权,即“假市值”。中证转债指数类似这种
srsIssue =
get_issueamount
(codes)
srsFakeEv = obj.DB[
'Close'
].loc[date,codes] * srsIssue
return
srsFakeEv / srsFakeEv.
sum
()
elif method ==
'Ev'
:
#
市值加权
srsOutstanding = obj.DB[
'Outstanding'
].loc[date,codes]
srsEv = obj.DB[
'Close'
].loc[date, codes] *srsOutstanding
return
srsEv / srsEv.
sum
()
elif
elif hasatrr(method,’__call__’)
:
return method(obj, codes, date)
调仓周期函数:
比较简单,不过这里我们只留了两种形式,一种是每日调仓(但这个其实没有想象中那么实用),另一种是每隔固定交易日调仓一次。实现如下:
def
roundOfAdjust
(obj, start, method=
'daily'
):
i =
getStartLoc
(obj,start)
if
method ==
'daily'
:
return
obj.DB[
'Amt'
].index[i:]
elif
isinstance
(method,int):
#
这里有一个值得注意,验证数据类型,不要用
type(data) == ...
,而是
instance
# [::n]
就是每隔
n
个数取一次了
return
obj.DB[
'Amt'
].index[i:][::method]
最后是checkBook:
也就是对于账簿的每日处理函数。这个内容比较简单,值得注意的是:1、这个函数没有任何返回值,但dfRet、dfAssetBook乃至cash都会被它改变,这是python的一个特性,可以多加利用;2、cash的意义是在于仓位不满或者超过100%时,记录现金的成本或者收益
def
checkBook
(obj, dfRet, dfAssetBook, cash, date,cashRate = 0.03):
if
date == dfRet.index[0]:
dfRet.loc[date][
'NAV'
] = 100
else
:
i = dfRet.index.
get_loc
(date); j = obj.DB[
'Close'
].index.
get_loc
(date)
if
len
(dfAssetBook.index)== 1 and dfAssetBook.index[0] ==
'Nothing'
:
dfRet.iloc[i][
'NAV'
] =dfRet.iloc[i-1][
'NAV'
] * (1 + cashRate/252.0)
cash *= 1 + cashRate / 252.0
else
:
codes =
list
(dfAssetBook.index)
srsPct = obj.DB[
'Close'
].iloc[j-1:j+1][codes].
pct_change
().iloc[-1] + 1.0
cashW = 1 - dfAssetBook[
'w'
].
sum
()
t1 = (srsPct *dfAssetBook['costPrice'] * dfAssetBook['w']).
sum
()+ cash * cashW * (1 + cashRate)
t0 = (dfAssetBook[
'costPrice'
]* dfAssetBook[
'w'
]).
sum
() + cash * cashW
dfRet.iloc[i][
'NAV'
] =dfRet.iloc[i-1][
'NAV'
] * t1 / t0
cash *= 1 + cashRate
最后的最后,是对主函数frameStrategy的修正
——因为输入参数绝不止obj和起始时间start。结合上面几个小函数的讨论,至少这几个参数是可以留给投资者自设的(当然沿用默认设置也没问题):1、defineCodes中的method,用来调整择券范围;2、selectCode中的method,用来调整核心策略;3、getWeight中的method,用来调整加权方法;4、调仓周期的参数。因此,这个函数的def行应该是这样的:
def
frameStrategy
(obj,
start
='2015/12/31',
defineMethod
='default',
selMethod=
None
,
weightMethod
='average',
roundMethod
='daily')
投资者不仅可以用来测试策略,自定义指数也可以比较轻易地计算,而不用再依赖卖方提供的数据了
—— 而且相对于别人的数据,投资者会更清楚地理解自己编写的指数。比如上面的案例可以作为低价品种等权指数,稍作改动就可以变成“高价指数”。再如,将下面的函数作为selMethod传入框架,可以得到低溢价率品种指数:
def
_
lowPrem
(obj, codes, date, tempCodes):
avgPrem= obj.DB['ConvPrem'].loc[date][tempCodes].mean()
return
obj
.DB[' ConvPrem '].loc[date, codes] <= avgPrem
上面提到的这几个指数如下图: