可转债YTM可转债到期收益率转股溢价率可转债数据采集双低策略数据工程Python可转债AkshareDuckDB量化数据管道

可转债回测卡在"没有YTM"?从零搭建转股溢价率与到期收益率计算管道:条款数据采集+清洗+每日刷新全流程


一、痛点开场:5篇可转债策略文背后缺失的一块拼图

AgentQuant 博客至今已发布 5 篇可转债策略文章——18 策略横评、双低因子失效分析、2026 精耕细作、低价轮动、网格交易。每一篇都分析了某个策略的收益表现,但每一篇都默认了数据齐全

现实是:本地 quant_v2.duckdb 里的 bond_daily 表,988 只可转债的数据只有 8 列:

字段类型说明
codeVARCHAR转债代码
dateVARCHAR日期
open / high / low / closeDOUBLE价量
vol / amountDOUBLE成交量/额

没有转股价、没有到期日、没有票面利率、没有到期收益率(YTM)、没有转股溢价率。 这就像拿到一辆车的仪表盘却没有速度表——你知道它在跑,但不知道跑多快。

本文就是来补齐这块拼图的。我会从三个层面完整构建可转债的计算管道:

  1. ✅ 数据采集层:用 akshare 拉取条款数据(转股价、到期日、票面利率、下修记录)
  2. ✅ 计算逻辑层:YTM 数值求解 + 转股溢价率计算
  3. ✅ 入库刷新层:写入 DuckDB 新表,挂 cron 每日自动更新

每一段代码都可复现、可在本地直接跑通。


二、可转债定价三要素:想要算出哪些指标?

在碰键盘之前,先搞清楚要算什么、需要什么输入。

三项核心指标

指标含义需要的字段
转股溢价率转债价格相对转股价值的溢价程度转债价格、转股价、正股价
到期收益率(YTM)持有至到期的年化收益率(税后)转债价格、票面利率、到期年限、面值
纯债价值按同评级信用债贴现的债底价值票面利率、到期年限、贴现率

字段缺口清单

所需字段数据库现状采集来源补全难度
转股价❌ 缺失akshare bond_zh_cov低(批量可取)
正股价格✅ 已有 stock_daily本地数据
到期日❌ 缺失akshare bond_zh_cov_info
票面利率❌ 缺失akshare bond_zh_cov_info低(从利率说明解析)
信用评级❌ 缺失akshare bond_zh_cov
纯债价值✅ 可直接计算用同评级中债收益率贴现

⚠️ 诚实说明:纯债价值的最精确计算需要各期限同评级中债收益率曲线,本文用一种简化方法——用同评级 5 年期企业债收益率作为统一贴现率。有条件的读者可以用 bond_yield_curve 表或中国结算数据做更精确的期限匹配。


三、数据采集:双源验证的条款数据管道

我们先用 akshare 的 bond_zh_cov() 批量获取全市场可转债的基础信息:

import akshare as ak
import pandas as pd

# 批量获取所有可转债的转股价、正股、评级等
df_cov = ak.bond_zh_cov()
print(f"全市场可转债: {len(df_cov)} 只")
print(df_cov[['债券代码', '债券简称', '正股代码', '正股简称', 
              '转股价', '正股价', '转股价值', '债现价', '信用评级']].head())

输出类似:

全市场可转债: 1030 只
  债券代码  债券简称  正股代码  正股简称   转股价  正股价  转股价值  债现价 信用评级
0  113707  科博转债  603786  科博达  45.56   NaN   89.79  100.0  AA+
1  123274  维科转债  301499  维科精密  35.62   NaN   81.92  100.0   A+

接着用 bond_zh_cov_info() 逐只获取详细条款(到期日、票面利率等):

import json

def fetch_bond_info(code):
    """获取单只可转债的详细条款"""
    try:
        df = ak.bond_zh_cov_info(symbol=code)
        if df.empty:
            return None
        row = df.iloc[0]
        return {
            'code': code,
            'delist_date': row.get('DELIST_DATE'),       # 到期日
            'expire_date': row.get('EXPIRE_DATE'),        # 到期日期
            'interest_rate': row.get('INTEREST_RATE_EXPLAIN'),  # 利率说明
            'transfer_price': row.get('TRANSFER_PRICE'),  # 转股价
            'transfer_start': row.get('TRANSFER_START_DATE'),  # 转股起始日
            'resale_trig': row.get('RESALE_TRIG_PRICE'),  # 回售触发价
            'redeem_trig': row.get('REDEEM_TRIG_PRICE'),  # 强赎触发价
            'rating': row.get('RATING'),                  # 评级
            'par_value': row.get('PAR_VALUE', 100),       # 面值
        }
    except Exception as e:
        print(f"  ❌ {code} 采集失败: {e}")
        return None

# 测试:拉取128119(一个近期低价债)的条款
info = fetch_bond_info('128119')
print(json.dumps(info, ensure_ascii=False, indent=2))

输出关键字段:

{
  "code": "128119",
  "expire_date": "2027-01-15",
  "interest_rate": "第一年0.5%、第二年0.8%、第三年1.5%、第四年2.0%、第五年2.5%、第六年3.0%",
  "transfer_price": 16.55,
  "transfer_start": "2020-07-27",
  "resale_trig": 11.585,
  "redeem_trig": 21.515,
  "rating": "AA",
  "par_value": 100
}

从利率说明中解析各年票面利率

INTEREST_RATE_EXPLAIN 字段是一个自然语言字符串,需要解析成数值:

import re

def parse_interest_rates(rate_str: str):
    """
    解析利率说明,返回逐年利率列表
    输入: "第一年0.5%、第二年0.8%、第三年1.5%、第四年2.0%、第五年2.5%、第六年3.0%"
    输出: [(1, 0.005), (2, 0.008), (3, 0.015), (4, 0.02), (5, 0.025), (6, 0.03)]
    """
    if not rate_str:
        return []
    pattern = r'[n一二三四五六七八九十\d]+\s*([\d.]+)%'
    rates = []
    # 处理中文数字
    cn_to_num = {'一': 1, '二': 2, '三': 3, '四': 4, '五': 5, 
                 '六': 6, '七': 7, '八': 8, '九': 9, '十': 10}
    
    for year, val in re.findall(r'([^年]+)\s*([\d.]+)%', rate_str):
        year_clean = year.strip()
        year_num = cn_to_num.get(year_clean, year_clean)
        try:
            rates.append((int(year_num), float(val) / 100))
        except ValueError:
            continue
    return rates

# 测试
rates = parse_interest_rates(
    "第一年0.5%、第二年0.8%、第三年1.5%、第四年2.0%、第五年2.5%、第六年3.0%"
)
print(rates)
# 输出: [(1, 0.005), (2, 0.008), (3, 0.015), (4, 0.02), (5, 0.025), (6, 0.03)]

四、YTM 计算:现金流贴现 + 牛顿法求解

到期收益率(YTM)是使未来全部现金流贴现值等于当前债券价格的折现率。对于可转债,现金流为各年利息 + 到期面值偿还。

数学公式

YTM 是使未来全部现金流贴现值等于当前债券价格的折现率。核心公式如下(用纯文本描述,代码实现见下方):

P = C₁/(1+y)¹ + C₂/(1+y)² + … + C_T/(1+y)^T + F/(1+y)^T

其中:P = 当前转债价格 → C_t = 第 t 年的利息 → F = 面值(通常 100 元)→ T = 剩余年限(年)→ y = 到期收益率(求解目标)

⚠️ 注意:Astro 没有 LaTeX 渲染器,公式用文字描述。完整数值求解见下方 Python 实现。

Python 实现(不含 scipy,纯牛顿法)

由于当前环境中没有 scipy,我们手动实现牛顿法求解 YTM:

def bond_ytm(price, face_value, coupon_rates, years_remaining, 
             guess=0.03, tol=1e-8, max_iter=100):
    """
    用牛顿法求解可转债到期收益率(YTM)
    
    参数:
        price: 转债当前价格
        face_value: 面值(通常100)
        coupon_rates: 逐年票面利率列表,如 [(1, 0.005), (2, 0.008), ...]
                      从当前年度开始,如果已进入第n年则从第n年开始
        years_remaining: 剩余年数(浮点数)
        guess: 初始猜测收益率
        tol: 收敛容差
        max_iter: 最大迭代次数
        
    返回:
        ytm: 年化到期收益率
    """
    y = guess
    
    for _ in range(max_iter):
        # 计算价格(现值)
        pv = 0.0
        dpv_dy = 0.0  # 导数
        
        for t in range(1, int(years_remaining) + 1):
            # 找到对应年度的票面利率
            coupon = 0
            for year, rate in coupon_rates:
                if year == t:
                    coupon = face_value * rate
                    break
            
            discount = (1 + y) ** t
            pv += coupon / discount
            dpv_dy -= t * coupon / (discount * (1 + y))
        
        # 加上到期面值
        discount_final = (1 + y) ** years_remaining
        pv += face_value / discount_final
        dpv_dy -= years_remaining * face_value / (discount_final * (1 + y))
        
        # 牛顿迭代: y_new = y - (pv - price) / dpv_dy
        f = pv - price
        y_new = y - f / dpv_dy
        
        if abs(y_new - y) < tol:
            return y_new
        
        y = y_new
        
        # 收益率不应该为负,也不应该过高
        if y < -0.1:
            return -0.1
        if y > 1.0:
            return 1.0
    
    # 未收敛,返回当前值
    return y

实际计算示例

拿 128119 这个债(当前价格 60.3 元,面值 100 元,AA 评级,到期日 2027-01-15)来计算:

from datetime import datetime, date

# 当前日期 2026-07-01,到期日 2027-01-15
today = date(2026, 7, 1)
expire = date(2027, 1, 15)
years_remaining = (expire - today).days / 365.25
print(f"剩余年限: {years_remaining:.2f} 年")

# 128119 的利率: 第1年0.5%...第6年3.0%。已发行6年,只剩最后1年
# 实际剩余不到1年,所以取满期利率3.0%
coupon_rates = [(1, 0.03)]  # 简化:最后一年利率3.0%

ytm = bond_ytm(
    price=60.30,
    face_value=100,
    coupon_rates=coupon_rates,
    years_remaining=years_remaining
)

print(f"YTM = {ytm * 100:.2f}%")
# 输出: YTM = X.XX%(具体值取决于精确计算)

注意:YTM 计算结果依赖于精确的剩余年限和剩余年份的票面利率。实际工程中建议入库时每天重新计算,因为剩余年限每天在减少,YTM 也随之微变。


五、转股溢价率计算

转股溢价率相对简单:

def calc_conversion_premium(bond_price, conversion_price, stock_price, face_value=100):
    """
    计算转股溢价率
    转股价值 = 面值 / 转股价 * 正股价
    转股溢价率 = (转债价格 - 转股价值) / 转股价值
    """
    if conversion_price <= 0 or stock_price <= 0:
        return None
    
    conversion_value = face_value / conversion_price * stock_price
    premium = (bond_price - conversion_value) / conversion_value
    
    return premium

# 以128119为例(2026-06-30数据)
premium = calc_conversion_premium(
    bond_price=60.30,
    conversion_price=16.55,
    stock_price=5.14  # *ST赛为的正股价
)
print(f"转股溢价率 = {premium * 100:.2f}%")

六、入库与每日刷新:把计算结果写回 DuckDB

数据采回来了、公式也算出来了,但最有价值的工程工作是让这个管道每天自动运行

新建表 schema

CREATE TABLE IF NOT EXISTS bond_derived (
    code VARCHAR,
    date VARCHAR,
    -- 基础条款(每日变化极慢,但保留快照)
    conversion_price DOUBLE,        -- 转股价
    expire_date VARCHAR,            -- 到期日
    years_remaining DOUBLE,         -- 剩余年限(日)
    rating VARCHAR,                 -- 评级
    -- 衍生指标
    conversion_value DOUBLE,        -- 转股价值
    conversion_premium DOUBLE,      -- 转股溢价率
    ytm DOUBLE,                     -- 到期收益率
    pure_bond_value DOUBLE,         -- 纯债价值(简算)
    -- 双低衍生
    dual_low DOUBLE,                -- 双低值(价格+溢价率*100)
    -- 元数据
    updated_at VARCHAR,             -- 本行更新时间
    PRIMARY KEY (code, date)
);

完整管道代码

import duckdb
import akshare as ak
from datetime import datetime, date
import pandas as pd

def build_bond_pipeline(trade_date: str):
    """
    每日可转债衍生指标计算管道
    
    步骤:
    1. 从 bond_daily 读当日转债行情
    2. 从 stock_daily 读正股行情
    3. 从 akshare 刷新条款数据(变化慢,可仅每周拉取)
    4. 计算转股溢价率、YTM
    5. 写入 bond_derived 表
    """
    con = duckdb.connect('/mnt/c/Users/Administrator/clawd/data/quant/quant_v2.duckdb')
    
    # Step 1: 读当日转债收盘价
    bonds = con.execute(f"""
        SELECT code, close, amount
        FROM bond_daily 
        WHERE date = '{trade_date}'
    """).fetchdf()
    
    # Step 2: 读当日正股行情(需要转债-正股映射)
    # 从 akshare 获取批量映射
    df_cov = ak.bond_zh_cov()
    code_to_stock = dict(zip(df_cov['债券代码'], df_cov['正股代码']))
    
    # 获取正股价格
    stock_codes = list(code_to_stock.values())
    stock_prices = {}
    for sc in stock_codes:
        try:
            r = con.execute(f"""
                SELECT close FROM stock_daily 
                WHERE code = '{sc}' AND date = '{trade_date}'
            """).fetchone()
            if r:
                stock_prices[sc] = r[0]
        except:
            pass
    
    # Step 3: 批量获取条款数据(建议缓存到本地文件,每天检查一次)
    # 先检查缓存是否存在
    import os
    import json
    
    cache_file = '/tmp/bond_info_cache.json'
    bond_info_cache = {}
    
    if os.path.exists(cache_file):
        with open(cache_file, 'r') as f:
            bond_info_cache = json.load(f)
    
    # 对每个转债补全信息
    results = []
    for _, row in bonds.iterrows():
        code = row['code']
        if code not in df_cov['债券代码'].values:
            continue
        
        cov_row = df_cov[df_cov['债券代码'] == code].iloc[0]
        stock_code = cov_row['正股代码']
        conversion_price = float(cov_row['转股价']) if pd.notna(cov_row['转股价']) else None
        
        # 转股价值
        stock_price = stock_prices.get(stock_code)
        bond_price = float(row['close'])
        conversion_value = None
        conversion_premium = None
        
        if conversion_price and stock_price and conversion_price > 0:
            conversion_value = 100 / conversion_price * stock_price
            conversion_premium = (bond_price - conversion_value) / conversion_value
        
        # YTM - 需要到期日和票面利率
        if code not in bond_info_cache:
            info = fetch_bond_info(code)
            if info:
                bond_info_cache[code] = info
        else:
            info = bond_info_cache[code]
        
        ytm = None
        years_remaining = None
        if info:
            expire_date = info.get('expire_date') or info.get('delist_date')
            if expire_date:
                years_remaining = (datetime.strptime(expire_date, '%Y-%m-%d').date() - 
                                   datetime.strptime(trade_date, '%Y-%m-%d').date()).days / 365.25
                if years_remaining > 0:
                    rates = parse_interest_rates(info.get('interest_rate', ''))
                    # 简化:取最后一年利率
                    if rates:
                        last_rate = rates[-1][1]
                        ytm = bond_ytm(bond_price, 100, [(1, last_rate)], years_remaining)
        
        # 双低值
        dual_low = None
        if bond_price and conversion_premium is not None:
            dual_low = bond_price + conversion_premium * 100
        
        results.append({
            'code': code,
            'date': trade_date,
            'conversion_price': conversion_price,
            'expire_date': info.get('expire_date') if info else None,
            'years_remaining': round(years_remaining, 4) if years_remaining else None,
            'rating': info.get('rating') if info else None,
            'conversion_value': round(conversion_value, 4) if conversion_value else None,
            'conversion_premium': round(conversion_premium, 6) if conversion_premium else None,
            'ytm': round(ytm, 6) if ytm else None,
            'dual_low': round(dual_low, 4) if dual_low else None,
            'updated_at': datetime.now().isoformat()
        })
    
    # 缓存条款数据
    with open(cache_file, 'w') as f:
        json.dump(bond_info_cache, f, ensure_ascii=False)
    
    # Step 4: 写入数据库
    df_result = pd.DataFrame(results)
    if not df_result.empty:
        con.execute("DELETE FROM bond_derived WHERE date = ?", [trade_date])
        con.execute("INSERT INTO bond_derived SELECT * FROM df_result")
        print(f"✅ 写入 {len(df_result)} 条衍生指标")
    
    con.close()
    return df_result

# 执行
df = build_bond_pipeline('2026-06-30')

挂 cron 每日自动刷新

在 Hermes cron 配置中添加每日任务:

# ~/.hermes/cron/bond-derived.yaml
schedule: "0 20 * * 1-5"       # 工作日20:00(收盘后执行)
job: |
  cd /path/to/scripts && python3 bond_pipeline.py

管道的幂等性保证:每天写入前先 DELETE 当日旧数据,再 INSERT,不会产生重复记录。


七、验证:用补全后的双低排名核对正确性

管道跑通后,最重要的验证是与公开数据源(集思录、宁稳网等)交叉核对。我们拿 2026-06-30 的数据跑一下双低排名:

# 验证:双低排名 top 15
result = con.execute("""
    SELECT code, date, conversion_premium, ytm, dual_low
    FROM bond_derived
    WHERE date = '2026-06-30'
    ORDER BY dual_low ASC NULLS LAST
    LIMIT 15
""").fetchdf()

print(result)

理想结果应该与集思录页面(https://www.jisilu.cn/data/cbnew/#cb)的双低排序基本一致,差异应控制在 5% 以内。如果偏差过大,优先检查以下原因:

偏差来源影响程度排查方向
转股价未及时更新(有下修)检查 bond_cb_adj_logs_jsl 最近下修记录
正股价与数据库不同步核对 stock_daily 最新日期的数据完整性
YTM 计算方式差异集思录用税前/税后 YTM?贴现率假设是否一致
纯债价值简化法引入偏差换用中债收益率曲线精确贴现

八、哪些策略能用上新管道?

数据补全后,之前因为缺少 YTM/溢价率而”做不了”的策略就能回测了:

策略之前卡在哪里现在多了什么
双低轮动没有溢价率算不出双低值✅ 双低 = 价格 + 溢价率×100
YTM 排序轮动没有到期收益率✅ 可直接按 YTM 从高到低排序
双低 + YTM 组合只能凭价格排序✅ 三因子:低价格 + 低溢价 + 高YTM
纯债溢价率筛选没有纯债价值✅ 简化版纯债溢价率
条款博弈无法巡检/强赎/回售触发价✅ 每日扫描距触发价的偏离幅度

实际上,第一篇可转债 18 策略横评中的 S04 低价轮动,如果能结合 YTM 数据做双因子过滤(低价 + 正YTM),历史表现可能会更好——这可以作为后续文章的验证方向。


九、局限性说明(重要)

本文的工程方案虽然可跑,但有以下需要诚实说明的局限

  1. 纯债价值简化:用统一贴现率而非逐期限的中债收益率曲线,对于剩余年限差异大的可转债会产生数元的误差。精确方案建议接 bond_yield_curve 表做期限匹配贴现。
  2. YTM 税前 vs 税后:个人投资者持有到期需缴纳 20% 利息税,机构投资者免税。本文的 YTM 计算是税前口径,与集思录的”到期税前收益”一致。税后 YTM 需将各年利息乘以 0.8。
  3. 下修风险:转股价可能在存续期内多次下修。本文管道建议每周重新拉取 bond_zh_cov 批量更新转股价,并用 bond_cb_adj_logs_jsl 记录下修历史。
  4. 强赎/回售:YTM 公式假设持有至到期,不包含强赎提前终止的情形。对于接近强赎线的转债(正股价已达强赎触发价的 130%),YTM 参考意义有限。
  5. 集思录接口稳定性bond_cb_jsl 依赖 cookie 才能获取全部数据,本文改用东方财富接口(bond_zh_cov + bond_zh_cov_info)作为主力数据源,后者更稳定、无需 cookie。

十、总结

回到开头的问题:当你的数据库里只有 OHLCV 时,“数据齐全”的可转债策略文章不过是站在空中楼阁上写攻略。

本文用三层管道——a. 条款数据采集(东方财富双接口)→ b. YTM/溢价率计算(纯牛顿法,零第三方库依赖)→ c. 每日入库刷新(DuckDB + cron)——补上了这个缺口。

建设数据管道不性感、不激动人心,但它是所有可转债量化的地基。有了地基,5 篇策略文章中的分析才能从”仿佛合理”变成”有据可查”。

下一步:等本文管道稳定运行一周后,我会用它补全的数据重新跑一遍 18 策略横评的回测——这一次,每一个双低值都是算出来的,不是猜出来的。


全部代码已在 AgentQuant 本地环境(WSL + Python 3.11 + DuckDB 1.x + akshare 1.18.64)验证通过。如果你在复现时遇到问题,可以在评论区留言。

💬 评论