用TS手把手实现可转债双低轮动策略
背景
可转债双低轮动策略是一个理论上可行的策略,该策略来自集思录-yyb凌波-转债双低轮动+沪深300波动率策略(2024)。该策略配合沪深300波动率策略已经过yyb凌波大佬的实战验证。但该策略进入条件比较苛刻,不容易触发。
策略介绍
可转债可以根据可转债价格
和转股溢价率
分成四个象限,四象限及对应特点如下:
如上图所示,当可转债价格
接近债底,转股溢价率
也低时,出现了投资机会,可以重仓;当可转债价格
接近债底,转股溢价率
高时,可以择机投资。可转债双低轮动策略的核心就是找可转债价格
接近债底同时转股溢价率
也低的情况。
注:
转股溢价率 = (转债价格 - 转股价值) / 转股价值 * 100%
转股价值 = 正股价格 × (可转债发行价 / 转股价格)
,可转债每张发行价通常为100元。
需求分析
现在围绕可转债双低轮动策略的核心,结合自己的理解,做了一些改动,整理了以下核心需求。
策略条件
进入条件:市场上可转债双低均值小于150
。
标的排除条件:
- 已触发强赎;
- 1年内到期(此时期权价值太低了);
- 非A级债;
- 可转债价格>110
- 转股溢价率>50
- 双低值>130
标的数量:
- 可转债总数50只以内时,不满足标的选择条件。
- 可转债总数100只以内时,采用5只轮动。
- 可转债总数200只以内时,采用10只轮动。
- 可转债总数300只以内时,采用15只轮动。
- 可转债总数300只以上时,采用20只轮动。
标的选择条件:标的执行排除条件后,可转债按照双低值从低到高
排序,标的数量满足轮动,按照规定标的数量,按序获取足够的标的。注:优先选择规模小的转债。
轮动策略
轮动方式:当标的双低值大于整体可转债双低排名20%后,且轮出标的-新标的>=10。
观察周期:每周。
轮动周期:半个月后若净值历史新高,则按半个月轮动,否则用一个月轮动。
退出条件
减仓条件:双低均值大于165
。
退出条件:双低均值大于170
或者标的数量不满足轮动。
策略实现
实现准备
我们将基于iStock Shell
基础去开发一个查看可转债双低轮动策略的命令。我们可在gitee上获取iStock Shell代码
,可以在iStock Shell官网文档
查看详细的使用和开发文档。然后将iStock Shell
在本地开发环境跑起来。
为了实现可转债双低轮动策略,我们需要获取所有可转债的信息,这可以在集思录网站实时数据-可转债
找到对应接口。接口地址为:https://www.jisilu.cn/webapi/cb/list/,请求方法为get。
初始命令
在iStock Shell
源码根目录,运行istock cmd init
命令,命令域输入kzz
(可转债),命令文件夹输入kzzsd
(可转债双低)。如下所示:
PS D:\project\myself\istock-shell> istock cmd init
? 在哪个命令域下开发命令? kzz
? 您期望命令相关文件名为?(文件名用-符号分割) kzzsd
kzz命令域文件夹创建成功
初始化命令开发已完成
定义模型
在src/worker/domains/kzz/kzzsd
目录kzzsd.model.ts
文件编写如下代码,用来对接集思录的可转债列表。
import { BaseModel, Model } from '@istock/iswork';
@Model('kzzsd')
export class KzzsdModel extends BaseModel {
// 转债代码
bond_id!: string;
// 转债名称
bond_nm!: string;
// 转债现价
price!: number;
// 转股价值
convert_value!: number;
// 转股溢价率
premium_rt!: number;
// 双低值
dblow!: number;
// 评级
rating_cd!: string;
// 到期时间
maturity_dt!: string;
// 剩余年限
year_left!: number;
// 剩余规模(亿元)
curr_iss_amt!: number;
// 债券类型
btype!: string;
// 正股名称
stock_nm!: string;
// 正股价格
sprice!: number;
// 强赎价价格
force_redeem_price!: number;
// 标记状态
status?: string[];
}
在src/worker/domains/kzz/kzzsd
目录kzzsd-result.model.ts
文件编写如下代码,用来执行双低策略后的结果保存到本地数据库。
import { BaseModel, Column, Index, Model, PrimaryColumn } from '@istock/iswork';
@Model('kzzsd_result')
export class KzzsdResultModel extends BaseModel {
@Index()
@PrimaryColumn()
id!: string;
// 转债代码
@Index()
@Column()
bond_id!: string;
// 时间
@Column()
updateDate!: Date;
@Column()
rowStatus!: number;
}
定义服务
根据整理的需求,实现该策略的核心逻辑。在src/worker/domains/kzz/kzzsd
目录kzzsd.service.ts
文件编写如下代码:
import { Injectable, type TModelData } from '@istock/iswork';
import { CookieService } from '@domains/global/setting/cookie/cookie.service';
import { KzzsdModel } from './kzzsd.model';
import { KzzsdResultModel } from './kzzsd-result.model';
import { isArray, isString, ScopeError } from '@/packages/util';
@Injectable()
export class KzzsdService {
readonly #site = 'https://www.jisilu.cn';
constructor(private readonly cookieService: CookieService) {}
/**
* 获取集思录可转债列表数据
*/
async findJisiluCbList() {
// 获取集思录的cookie数据
const cookieData = await this.cookieService.findOneByHost(this.#site);
const jisiluData = await KzzsdModel.run<{
data: Array<TModelData<KzzsdModel>>;
prompt: string;
}>('/webapi/cb/list/', {
method: 'get',
query: { _: Date.now() },
headers: { 'x-target': this.#site, 'x-cookie': cookieData?.cookie || '', init: 1 },
});
if (jisiluData.prompt) {
throw new ScopeError(`kzzsd.${this.constructor.name}`, jisiluData.prompt);
}
return jisiluData.data.filter((item) => {
// 排除非可转债
return ['C'].includes(item.btype);
});
}
/**
* 按照双低值从低到高排序
* @param list 可转债列表
*/
sortByDblow(list: Array<TModelData<KzzsdModel>>) {
return list.sort((v1, v2) => {
return v1.dblow - v2.dblow;
});
}
/**
* 可转债筛选策略
* @param averageDblow 双低最大进入条件
* @param cycle 轮动周期
*/
async dblowStrategy(averageDblow: number, cycle: number) {
const jisiluList = this.sortByDblow(await this.findJisiluCbList());
const kzzsdResult = await KzzsdResultModel.query({ filter: ['rowStatus', 'eq', 1] });
const jisiluListRecord = jisiluList.reduce<Record<string, TModelData<KzzsdModel>>>((record, item) => {
record[item.bond_id] = item;
return record;
}, {});
const lastResult: Array<TModelData<KzzsdModel>> = kzzsdResult
.map((item) => {
return jisiluListRecord[item.bond_id];
})
.filter((item) => item);
const lastTime = kzzsdResult[0]?.updateDate?.getTime?.() || 0;
const hasRunDblowStrategy = Boolean(lastResult.length);
if (hasRunDblowStrategy && cycle * 24 * 60 * 60 * 1000 > Date.now() - lastTime) {
// 已进入策略但并未到策略执行周期,直接返回上次策略执行结果
return {
table: this.toTableData(lastResult),
texts: [
{
type: 'danger',
text: '未到该策略执行周期',
},
],
};
}
// 未进入策略判断进入条件
if (!hasRunDblowStrategy && this.assertDblowStrategy(jisiluList, averageDblow)) {
return {
table: this.toTableData([]),
texts: [
{
type: 'danger',
text: `市场上可转债双低均值大于等于${averageDblow},不满足进入条件`,
},
],
};
}
// 获取排除条件后的结果
const filterResult = this.filterList(jisiluList);
let newResult: Array<TModelData<KzzsdModel>> = [];
let text: string = '';
if (jisiluList.length < 50 || filterResult.length < 5) {
if (hasRunDblowStrategy) {
await KzzsdResultModel.updateMany(
kzzsdResult.map((item) => {
item.rowStatus = 0;
return item;
})
);
}
return {
table: this.toTableData([]),
texts: [
{
type: 'danger',
text: '可转债总数50只以内或可选标的小于5时,不满足标的选择条件',
},
],
};
} else if (jisiluList.length < 100) {
// 可转债总数100只以内时,采用5只轮动
newResult = hasRunDblowStrategy
? this.replaceDblow(jisiluList, filterResult, lastResult, 5)
: filterResult.slice(0, 5);
if (newResult.length < 5) {
text = '可转债总数100只以内时,需要采用5只轮动,标的数量不满足轮动,已达到退出条件';
}
} else if (jisiluList.length < 200) {
// 可转债总数200只以内时,采用10只轮动
newResult = hasRunDblowStrategy
? this.replaceDblow(jisiluList, filterResult, lastResult, 10)
: filterResult.slice(0, 10);
if (newResult.length < 10) {
text = '可转债总数200只以内时,需要采用10只轮动,标的数量不满足轮动,已达到退出条件';
}
} else if (jisiluList.length < 300) {
// 可转债总数300只以内时,采用15只轮动
newResult = hasRunDblowStrategy
? this.replaceDblow(jisiluList, filterResult, lastResult, 15)
: filterResult.slice(0, 15);
if (newResult.length < 15) {
text = '可转债总数300只以内时,需要采用15只轮动,标的数量不满足轮动,已达到退出条件';
}
} else {
// 可转债总数300只以上时,采用20只轮动
newResult = hasRunDblowStrategy
? this.replaceDblow(jisiluList, filterResult, lastResult, 20)
: filterResult.slice(0, 20);
if (newResult.length < 20) {
text = '可转债总数300只以上时,需要采用20只轮动,标的数量不满足轮动,已达到退出条件';
}
}
await KzzsdResultModel.updateMany(
kzzsdResult.map((item) => {
item.rowStatus = 0;
return item;
})
);
await KzzsdResultModel.createMany(
newResult.map((item) => {
return {
id: KzzsdResultModel.generateId.nextId(),
bond_id: item.bond_id,
updateDate: new Date(),
rowStatus: 1,
};
})
);
if (text) {
return {
table: this.toTableData(this.addDblowListStatus(newResult, '退')),
texts: [
{
type: 'danger',
text,
},
],
};
}
if (hasRunDblowStrategy && this.assertDblowStrategy(jisiluList, 170)) {
return {
table: this.toTableData(this.addDblowListStatus(newResult, '退')),
texts: [
{
type: 'danger',
text: '双低均值大于170,已达到退出条件',
},
],
};
}
if (hasRunDblowStrategy && this.assertDblowStrategy(jisiluList, 165)) {
return {
table: this.toTableData(this.addDblowListStatus(newResult, '减')),
texts: [
{
type: 'warning',
text: '双低均值大于165,已达到减仓条件',
},
],
};
}
return {
table: this.toTableData(newResult),
texts: [],
};
}
/**
* 判断进入或退出条件 进入条件:双低均值<150 减仓条件:双低均值>165 退出条件:双低均值>170
* @param list 可转债列表
* @param maxValue 判断最大值
*/
assertDblowStrategy(list: Array<TModelData<KzzsdModel>>, maxValue: number): Boolean {
const total = list.reduce<number>((number, item) => {
number += item.dblow;
return number;
}, 0);
const average = Number((total / list.length).toFixed());
return average > maxValue;
}
/**
* 排除条件
* @param list 可转债列表
*/
filterList(list: Array<TModelData<KzzsdModel>>): Array<TModelData<KzzsdModel>> {
return list.filter((item) => {
// 1. 已触发强赎
if (!['C'].includes(item.btype)) return false;
// 2. 1年内到期(此时期权价值太低了)
if (item.year_left < 1) return false;
// 3. 非A级债
if (!item.rating_cd?.includes('A')) return false;
// 4. 可转债价格>110
if (item.price > 110) return false;
// 5. 转股溢价率>50
if (item.premium_rt > 50) return false;
// 6. 双低值>130
if (item.dblow > 130) return false;
return true;
});
}
/**
* 轮动双低值
* @param list 可转债列表
* @param filterResult 可转债条件排除后列表
* @param lastResult 上次策略结果
* @param count 获取个数
*/
replaceDblow(
list: Array<TModelData<KzzsdModel>>,
filterResult: Array<TModelData<KzzsdModel>>,
lastResult: Array<TModelData<KzzsdModel>>,
count: number
) {
const newResult: Array<TModelData<KzzsdModel>> = [];
const index = Math.round(list.length * 0.2); // 前20%
const offsetDblow = list[index].dblow;
const lastResultIds = lastResult.map((item) => item.bond_id);
const choiceList = filterResult.filter((item) => !lastResultIds.includes(item.bond_id)); // 可选列表
lastResult.forEach((item) => {
if (item.dblow > offsetDblow && choiceList[0]?.dblow && item.dblow - choiceList[0].dblow >= 10) {
// 当标的双低值大于整体可转债双低排名20%后,且轮出标的-新标的>=10
newResult.push({ ...choiceList[0], status: ['新'] });
choiceList.shift();
} else {
newResult.push({ ...item, status: [] });
}
});
if (newResult.length > count) {
return this.sortByDblow(newResult).slice(0, count);
}
return this.sortByDblow([...newResult, ...choiceList.slice(0, count - newResult.length)]);
}
/**
* 将结果数据转换成界面需要的表格数据
* @param list 可转债列表
*/
toTableData(list: Array<TModelData<KzzsdModel>>) {
const headerRecord: { [k in TModelData<KzzsdModel>]: string } = {
bond_id: '转债代码',
bond_nm: '转债名称',
price: '转债现价',
convert_value: '转股价值',
premium_rt: '转股溢价率',
dblow: '双低值',
rating_cd: '评级',
maturity_dt: '到期时间',
year_left: '剩余年限',
curr_iss_amt: '剩余规模(亿元)',
// btype: '债券类型',
stock_nm: '正股名称',
sprice: '正股价格',
force_redeem_price: '强赎价价格',
};
const headerKeys = Object.keys(headerRecord) as Array<keyof TModelData<KzzsdModel>>;
return {
caption: '可转债双低轮动策略',
thead: headerKeys.map((k) => {
return { value: headerRecord[k] };
}),
tbody: list.map((item) => {
return headerKeys.map((k) => {
if (k === 'bond_nm') {
return {
value: item[k] + (item.status ? `(${item.status?.join('、')})` : ''),
style: item.status?.length ? 'color: var(--color-primary-lighter)' : '',
};
}
if (k === 'premium_rt') {
return { value: item[k] + '%' };
}
return { value: item[k] };
});
}),
};
}
/**
* 添加状态
* @param list
* @param addStatus
*/
addDblowListStatus(list: Array<TModelData<KzzsdModel>>, addStatus: string | string[]) {
let status: string[] = [];
if (isString(addStatus)) {
status = [addStatus];
}
if (isArray(addStatus)) {
status = addStatus;
}
return list.map((item) => {
if (!item.status) item.status = [];
const newStatus = [...item.status, ...status];
item.status = [...new Set(newStatus)];
return item;
});
}
}
定义命令
iStock Shell
通过命令交互,我们需要定义一个命令绑定我最后控制器的执行方法上,这样就可以在界面上执行该命令。这里命令名定义为kzzsd
,在src/worker/domains/kzz/kzzsd
目录kzzsd.cmd.ts
文件编写如下配置:
const cmdRoute = {
name: '可转债双低',
cmd: 'kzzsd',
usage: 'kzzsd',
options: {
sdtj: {
name: 'sdtj',
parameter: ['-sdtj', '--双低条件'],
parameterType: ['number'],
description: `市场上可转债双低均值进入条件`,
optional: true,
default: 150,
},
ldzq: {
name: 'ldzq',
parameter: ['-ldzq', '--轮动周期'],
parameterType: ['number'],
description: `策略轮动周期,单位:天`,
optional: true,
default: 7,
},
},
description: '该策略来自集思录-yyb凌波-转债双低轮动+沪深300波动率策略(2024)',
source: {
title: '集思录-实时数据-可转债',
url: 'https://www.jisilu.cn/web/data/cb/list',
},
remarks: '需要设置集思录登录cookie',
example: 'kzzsd -ldzq 7 -sdtj 150',
};
export default {
[cmdRoute.name]: cmdRoute,
};
定义控制器
命令需要绑定到控制器,服务需要在控制器里使用,我们最终暴露的是控制器方法给界面上使用。在src/worker/domains/kzz/kzzsd
目录kzzsd.controller.ts
文件编写如下代码:
import { CmdRoute, Controller, Method, CmdRouteOptions } from '@istock/iswork';
import { KzzsdService } from './kzzsd.service';
import cmdJson from './kzzsd.cmd';
@Controller({
alias: 'kzzsd',
})
export class KzzsdController {
constructor(private readonly kzzsdService: KzzsdService) {}
// 命令控制器方法
@CmdRoute(cmdJson.可转债双低)
@Method({
alias: cmdJson.可转债双低.cmd,
})
async getCalendar(
@CmdRouteOptions(cmdJson.可转债双低.options.sdtj) averageDblow: number = 150,
@CmdRouteOptions(cmdJson.可转债双低.options.ldzq) cycle: number = 7
) {
const data = await this.kzzsdService.dblowStrategy(averageDblow, cycle);
return {
output: [
{
component: 'ShTable',
props: {
...data.table,
},
},
{
component: 'ShText',
props: {
texts: [
...data.texts,
{
type: 'info',
text: '该策略来自集思录-yyb凌波-转债双低轮动+沪深300波动率策略(2024),对应访问地址:',
tag: 'span',
},
{
type: 'info',
text: 'https://www.jisilu.cn/question/489447',
link: 'https://www.jisilu.cn/question/489447',
tag: 'a',
},
],
},
},
],
};
}
}
引用
KzzsdModel
和KzzsdResultModel
模型需要注册数据源,在src/worker/datasource-register.ts
文件中新增代码:
// ...
import { KzzsdModel } from '@domains/kzz/kzzsd/kzzsd.model';
import { KzzsdResultModel } from '@domains/kzz/kzzsd/kzzsd-result.model';
export const akShareFetchDataSourceModels = [
// ...
KzzsssjModel,
];
export const fetchDataSourceModels = [
// ...
KzzsdModel,
];
可转债双低策略实现代码需要导入到可转债应用域,在src/worker/domains/kzz/kzz.domain.ts
文件中新增代码:
// ...
import { KzzsdController } from './kzzsd/kzzsd.controller';
import { KzzsdService } from './kzzsd/kzzsd.service';
@Domain({
name: 'kzz',
viewName: '可转债',
imports: [SettingDomain],
providers: [
// ...
KzzsdService,
],
controllers: [
// ...
KzzsdController,
],
})
export class KzzDomain {}
测试
因为集思录的可转债列表需要登录才能获取到所有数据,所有我们第一个需要设置cookie。快捷键ctrl+shift+s
打开搜索面板。选择cookie管理。
用yyjr
命令进入可转债
应用,输入kzzsd
命令,结果:
由于当前可转债双低值在165-170之间,所以不满足进入条件。但为了测试,我们可以把命令参数设置成170就可以满足进入条件。输入命令kzzsd -sdtj 170 -ldzq 7
,结果:
7天周期过后,再次输入命令kzzsd -sdtj 170 -ldzq 7
查看轮动逻辑,结果:
结语
由于可转债具有股债双重属性,下跌时有债底保护,不至于出现巨额亏损;而当对应正股价格显著攀升时,可转债的股性特征增强,能够随正股上涨,所以它是不错的投资品种。为了针对性研究可转债,本文以实现可转债双低轮动策略为例,加深对可转债的理解,同时打通可转债可量化投资的条件。