作者

杨  冰 分析员, SAC 执业证书编号: S0080515120002

房  铎 联系人 SAC 执业证书编号: S0080117080049

姬江帆 分析员, SAC 执业证书编号: S0080511030008; SFC CE Ref: BDF391



简易的转债策略测试框架

策略好不好,测了才知道 —— 但对于转债来说,可能没那么容易。 虽然转债已经基本告别当年迷你市场的窘境,但依然是一个小市场,这是我们在年度回顾报告中的一个判断 —— 而附带的一个影响是,很多配套的东西还不完备,比如策略测试的代码框架。幸好开源的理念之下,这些事情自行处理也并不太复杂。我们在此介绍一个简单的测试框架及其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

上面提到的这几个指数如下图:


本文所引为报告部分内容,报告原文请见 2019 5 19 日中金固定收益研究发表的研究报告

相关法律声明请参照:

http://www.cicc.com/portal/wechatdisclaimer_cn.xhtml