可转债风险过滤后的低溢价策略

本文构建一套可转债低溢价选券策略:先以剩余期限、存量规模、价格和交易状态约束基础池,再按转股溢价率由低到高排序,选取横截面最低的 10%,单券目标权重不超过 2%。当日开盘获取上一个交易日的信号,在开盘执行交易。

1. 策略介绍

1.1 研究背景

可转债同时具有债性与股性。转股溢价率衡量投资者相对于转股价值支付的价格溢价,是观察转债估值的重要横截面指标。在正股走势、信用资质与流动性相近的条件下,较低的转股溢价率通常意味着:

  • 转债对正股上涨的跟随效率更高;

  • 估值压缩风险相对有限。

这一逻辑有两个边界:

  • 低溢价不等于低风险。 高价转债也可能具有很低的转股溢价率,但其价格波动更接近正股,债底保护有限。策略因此设置价格上限,并剔除异常低价品种。

  • 低溢价可能来自基本面恶化、信用担忧或流动性折价。 仅凭价格与转股价值无法识别这些风险,需要结合信用评级、正股风险警示、剩余规模、成交额、强赎进度等信息完善可投资池。当前样本字段有限,只实现了其中一部分约束。

1.2 核心因子

一张面值 100 元的可转债,其转股价值与转股溢价率定义为:

示例:可转债价格 115 元、转股价 10 元、正股价格 11 元,则:

在其他条件相同时,转股溢价率越低,可转债价格相对转股价值越便宜;但低溢价率并不等于低风险,还需结合价格、剩余期限、余额、信用与流动性共同判断。

DolphinDB 实现:

def bondPremRatio(bondPrice, convPrice, EQUprice){
    convValue = EQUprice * (100 \ convPrice)
    return (bondPrice \ convValue) - 1
}

1.3 策略构建

1.3.1 可选标的池

每个信号日使用当日可获得的数据动态筛选标的,条件如下:

约束 参数 目的
剩余期限 大于 0.25 年 回避临期转债及退出风险
剩余规模 不低于 2 亿元 控制小规模品种的流动性风险
转债价格 不低于 90 元且低于 120 元 排除异常低价券并保留价格安全边际
转股价 大于 0 保证因子可计算
当日成交量 大于 0 排除当日不可交易标的
因子值 非空 排除关键字段缺失的记录

1.3.2 排序与持仓

候选池按转股溢价率升序、证券代码升序排列,选取前 floor(N × 10%) 只。代码作为第二排序键,作用是使分位点并列时的结果可重复。若候选池非空,至少选取 1 只。

单券目标市值为组合权益的 2%,委托数量按 10 的整数倍向下取整:

1.3.3 调仓与成交

每个交易日开盘时获取上一个交易日收盘后的因子值,根据因子数据完成筛选与目标仓位计算,然后先卖后买:

  1. 开盘:更新候选池 → 计算溢价率 → 生成目标持仓

  2. 卖出:落选或超配品种

  3. 买入:新入选或低配品种

这一时序将因子观察与成交价格分开,避免使用收盘后才能确认的数据在同一收盘价成交。实际下单量同时受可卖数量、可用现金与委托费用约束。

避免未来函数:因子使用 prev(...) 取前一日值,信号在 T 日收盘后形成,T+1 日开盘执行。

2 策略回测

以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

下载 convBond.csv 和 convertibleBondsDividend.csv 文件,作为该策略回测的案例数据。运行脚本前需将 path 变量改为实际存放 CSV 文件的路径。

path ="/.../"
//筛选转债池
colName=`symbol`tradeTime`open`low`high`close`volume`amount`upLimitPrice`downLimitPrice`prevClosePrice`convPrice`maturity`remainSize`equityClose
colType=["SYMBOL","TIMESTAMP","DOUBLE","DOUBLE","DOUBLE","DOUBLE","LONG","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE"]
dayData = select * from loadText(filename=path+"convBond.csv",
  schema=table(colName as name ,colType as type )) order by tradeTime
symbolList=exec distinct symbol from dayData where maturity >0.25 and remainSze>=2 and close>=90
convertibleBondsDividend = loadText(path+"convertibleBondsDividend.csv")

回测行情表包含 15 个字段:

字段 类型 说明
symbol SYMBOL/STRING 可转债代码
tradeTime TIMESTAMP 行情时间
open、high、low、close DOUBLE 转债日线价格
volume、amount LONG、DOUBLE 成交量和成交额
upLimitPrice、downLimitPrice DOUBLE 涨跌停价格
prevClosePrice DOUBLE 前收盘价
convPrice DOUBLE 当期有效转股价
maturity DOUBLE 剩余期限,单位为年
remainSize DOUBLE 剩余规模,单位为亿元
equityClose DOUBLE 正股收盘价

可转债付息表包含的字段说明如下:

字段 含义
symbol 可转债代码
recordDate 登记日
payDate 可转债付息日
afterTaxCashDiv 可转债票面利率

2.2 编写策略逻辑

策略由四个函数组成:

  • bondPremRatio:转股溢价率因子计算;

  • selectPool:多条件筛选、综合评分与排序选标的;

  • initialize:初始化交易状态,订阅指标计算;

  • onBar:处理每日调仓执行。

在 onBar 中,msg 是以股票代码为键、以该股票当日行情为值的字典。每次回调先执行上一个信号日已经确定的目标。选中 N 只股票后,用信号日账户权益、1/N 的权重和收盘价计算目标股数。

因子计算函数:定义转股溢价率因子 bondPremRatio,公式为 (转债价格 / 转股价值) - 1,其中转股价值 = 正股价格 * (100 / 转股价)。

//转股溢价率
@state
def bondPremRatio(bondPrice,convPrice,EQUprice){
    convValue = EQUprice*(100\convPrice)
    return (bondPrice\convValue)-1
}

选股逻辑函数(selectPool):

  • 遍历当日有成交量的标的,提取因子值。

  • 按因子值升序排序,选取溢价率最低的 10%(topPercent=0.1)。

  • 同时要求当前可转债价格 低于 120 元。

    def selectPool(mutable context,msg,indicator){
    	factorValue =  array(DOUBLE,0,50)    
    	validStocks = array(STRING,0,50) 
    	for(i in msg.keys() ){  
    		factor = indicator[i]["factor"]      
    		if(!isNull(factor)  and msg[i].volume > 0  and msg[i].close<120.){  
    		    factorValue.append!(factor)  
    		    validStocks.append!(i)  
    		}  
    	} 
    	if(size(factorValue)<1) {
    		context["buyList"] = validStocks
    		return 
    	}
    	n = max(floor(size(factorValue) * context["topPercent"]),1)
    	buyList = validStocks[isortTop(factorValue,n,false)]
    	//选标的
    	context["buyList"] = buyList
    }

注意:isortTop(factorValue, n, false) 中 false 表示升序,与"选取溢价率最低"一致。候选池为空时 buyList 为空列表。

策略初始化( initialize ):

  • 订阅指标:在 initialize 中订阅因子,使用 prev 获取前一日的因子值,避免未来函数。

  • 设置股票池:通过 Backtest::setUniverse 将过滤后的可转债列表设为回测标的池。

    def initialize(mutable context){     
    	context["topPercent"]=0.1  //用来筛选因子的比例
    	context["maxPosition"]=0.02 //最大持仓比例
    	d = dict(STRING,ANY)  
    	d["factor"]=<prev(bondPremRatio(close,convPrice,equityClose))>
    	Backtest::subscribeIndicator(context["engine"], "kline", d)  
    	//通过setUniverse剔除雷券
    	Backtest::setUniverse(context["engine"], context["symList"])
    }

prev 的作用:因子在 T 日使用 T-1 日的值,确保信号形成时点早于执行时点,避免未来函数。

交易逻辑函数( onBar ):交易逻辑主要分为三个步骤,分别为根据信号选出可以买入的可转债池,当日没有选入的执行卖出操作,再执行买入操作。

  • 选标的:调用selectPool函数选出当日买入的标的。

  • 卖出:持仓中不在当日买入列表的标的全部卖出(调仓换股)。

  • 买入:对买入列表中的标的,计算目标持仓市值(总权益 * 2%),若当前持仓不足则补足。买入数量按 10 张的整数倍 计算,且需满足最小买入单位(代码中为 buyVolume >= 10)。

    def onBar(mutable context, msg, indicator){ 
    	//step 1:选标的池
    	selectPool( context,msg,indicator)
    	buyList = context["buyList"]  
    	 //step 2: 执行卖出
    	//获取当日卖出的标的池
    	pos = Backtest::getPosition(context["engine"])
    	sellList = array(STRING,0,10)  
    	tradeTime = context.tradeTime
    	if(size(pos)>0){
    		sellList = keys(set(pos.symbol)-set(buyList))	
    	
    	}
    	 //执行卖出
    	for(istock in sellList){  
    		pos = Backtest::getPosition(context["engine"], istock)  
    		longPos = pos.longPosition
    		
    		if(longPos > 0) {  
    			price = msg[istock]["open"] 
    			Backtest::submitOrder(context["engine"],   
    			    (istock, tradeTime, 5, price, longPos, 3), "sell")  
    		}  
    	}  
    	//step 3: 执行买入
    	k = size(buyList) 
    	if(k > 0) {  
    		equity = Backtest::getTotalPortfolios(context.engine)["totalEquity"][0]
    		cash = Backtest::getAvailableCash(context.engine)  
    		perCash = cash / k  
    		targetValue = equity * context["maxPosition"] 
    		for(istock in buyList){  
    			pos = Backtest::getPosition(context["engine"], istock)
    			longPos = 0
    			if(pos.longPosition > 0){
    			    longPos = pos.longPosition
    			}
    			price = msg[istock]["open"] 
    			currentValue = longPos * price  //当前持仓
    			if(currentValue < targetValue) {  
    			    maxValue = min(targetValue-currentValue, perCash) /(1+context.commission)
    			    buyVolume = int(maxValue / price / 10) * 10 
    			    if(buyVolume >= 10) {
    				Backtest::submitOrder(context["engine"],   
    				    (istock, tradeTime, 5, price, buyVolume, 1), "buy")  
    			    }  
    			}  
    		}  
    	}
    }

2.3 设置回测参数

  • 回测区间:2023.01.01 至 2024.01.01。

  • 初始资金:10,000,000 元。

  • 手续费:佣金 0.0002,无印花税。

  • 数据频率:日频(dataType:4)。

  • 撮合模式:matchingMode:3,开启卖出平仓限制。

  • 设置可转债付息:通过stockDividend的 convertibleBonds 设置可转债付息信息。

  • 上下文参数:topPercent=0.1(筛选比例),maxPosition=0.02(单券最大仓位 2%),symList 为初始过滤后的标的池。

    config = {
    	startDate: 2023.01.01,       
    	endDate:2024.01.01,         
    	strategyGroup: "stock" , 
    	cash: 10000000,  
    	commission: 0.0002 , 
    	tax: 0. ,  
    	dataType:4,   
    	outputOrderInfo: true,  
    	matchingMode:3, 
    	enableSellCloseRestrict:true, 
    	stockDividend:{convertibleBonds:convertibleBondsDividend},
    	context:{
    			topPercent:0.1, //选股筛选因子的比例
    			maxPosition:0.02, //每股最大持仓比例
    			commission:0.0002,
    			symList:symbolList//标的池
    		
    		}
    }

2.4 创建回测引擎

模拟撮合引擎负责订单撮合,Backtest 负责事件回调、持仓、资金与绩效管理。下面先检查并加载已经安装的插件,再注册初始化和日线回调。加载命令不会自动安装缺少的插件,需先在服务器端安装插件,参考:MatchingEngineSimulator、Backtest。

引擎通过 config 获取配置与上下文,通过 callbacks 找到策略函数。代码先尝试清理同名引擎实例,再创建新的引擎实例,避免重复使用包含历史状态的引擎。

callbacks={
	initialize:initialize ,
	onBar:onBar  
}

strategyName="demo"  
try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktester(strategyName, config, callbacks,, )
  • 使用 Backtest::createBacktester 创建名为 "demo" 的回测引擎。

  • 注册回调函数:initialize 和 onBar。

  • 传入配置参数和上下文。

2.5 执行回测

将排好时间顺序的行情表写入引擎,回调函数按交易日推进,依次执行信号计算和后续交易。最后通过 appendEndMarker 发送结束标记,并保留当前引擎实例,以便在下一环节提取回测指标与交易明细。

timer Backtest::appendQuotationMsg(engine,  dayData  ); 
Backtest::appendEndMarker(engine);

2.6 获取回测结果

处理结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。

returnSummary = Backtest::getReturnSummary(engine)
tradeDetails = Backtest::getTradeDetails(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)

各结果对象的主要用途如下:

  • returnSummary:查看引擎计算的收益、风险和交易统计。

  • tradeDetails:查看订单状态与成交。

  • dailyPosition:查看各日持仓、买卖数量和收盘估值。

  • totalPortfolios:查看每日账户权益和策略净值。

3. 总结

低溢价因子含义清晰,能够直接刻画转债价格与转股价值之间的相对关系;价格区间和期限、余额约束则为横截面排序提供了基本的风险边界。组合规则参数少,信号可以逐日复核,适合开展分层检验和归因分析。

本案例因子计算仅用价格与转股价值,未纳入信用评级、强赎进度,低溢价可能来自基本面恶化,后续可以纳入信用评级、强赎进度、成交额、正股波动率等因子进行策略改进。