可转债风险过滤后的低溢价策略
本文构建一套可转债低溢价选券策略:先以剩余期限、存量规模、价格和交易状态约束基础池,再按转股溢价率由低到高排序,选取横截面最低的 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 调仓与成交
每个交易日开盘时获取上一个交易日收盘后的因子值,根据因子数据完成筛选与目标仓位计算,然后先卖后买:
-
开盘:更新候选池 → 计算溢价率 → 生成目标持仓
-
卖出:落选或超配品种
-
买入:新入选或低配品种
这一时序将因子观察与成交价格分开,避免使用收盘后才能确认的数据在同一收盘价成交。实际下单量同时受可卖数量、可用现金与委托费用约束。
避免未来函数:因子使用 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. 总结
低溢价因子含义清晰,能够直接刻画转债价格与转股价值之间的相对关系;价格区间和期限、余额约束则为横截面排序提供了基本的风险边界。组合规则参数少,信号可以逐日复核,适合开展分层检验和归因分析。
本案例因子计算仅用价格与转股价值,未纳入信用评级、强赎进度,低溢价可能来自基本面恶化,后续可以纳入信用评级、强赎进度、成交额、正股波动率等因子进行策略改进。
