14 min read

用TS手把手实现可转债双低轮动策略

背景

可转债双低轮动策略是一个理论上可行的策略,该策略来自集思录-yyb凌波-转债双低轮动+沪深300波动率策略(2024)。该策略配合沪深300波动率策略已经过yyb凌波大佬的实战验证。但该策略进入条件比较苛刻,不容易触发。

策略介绍

可转债可以根据可转债价格转股溢价率分成四个象限,四象限及对应特点如下:
可转债象限划分

如上图所示,当可转债价格接近债底,转股溢价率也低时,出现了投资机会,可以重仓;当可转债价格接近债底,转股溢价率高时,可以择机投资。可转债双低轮动策略的核心就是找可转债价格接近债底同时转股溢价率也低的情况。

注:
转股溢价率 = (转债价格 - 转股价值) / 转股价值 * 100%
转股价值 = 正股价格 × (可转债发行价 / 转股价格),可转债每张发行价通常为100元。

需求分析

现在围绕可转债双低轮动策略的核心,结合自己的理解,做了一些改动,整理了以下核心需求。

策略条件

进入条件:市场上可转债双低均值小于150

标的排除条件:

  1. 已触发强赎;
  2. 1年内到期(此时期权价值太低了);
  3. 非A级债;
  4. 可转债价格>110
  5. 转股溢价率>50
  6. 双低值>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',
              },
            ],
          },
        },
      ],
    };
  }
}

引用

KzzsdModelKzzsdResultModel模型需要注册数据源,在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管理。
cookie设置
cookie设置2

yyjr命令进入可转债应用,输入kzzsd命令,结果:
kzzsd命令效果1

由于当前可转债双低值在165-170之间,所以不满足进入条件。但为了测试,我们可以把命令参数设置成170就可以满足进入条件。输入命令kzzsd -sdtj 170 -ldzq 7,结果:
kzzsd命令效果2

7天周期过后,再次输入命令kzzsd -sdtj 170 -ldzq 7查看轮动逻辑,结果:
kzzsd命令效果3

结语

由于可转债具有股债双重属性,下跌时有债底保护,不至于出现巨额亏损;而当对应正股价格显著攀升时,可转债的股性特征增强,能够随正股上涨,所以它是不错的投资品种。为了针对性研究可转债,本文以实现可转债双低轮动策略为例,加深对可转债的理解,同时打通可转债可量化投资的条件。