系列名:《从零搭建你的 A 股量化系统》| 专栏 S3 数据工程 | 第 13 篇 / 共 15 篇 | M5 特殊资产
标签:#量化投资 #数据工程 #可转债 #双低策略 #强赎 #回售 #转股溢价率 #AkShare #A股 #散户
上篇(S3-012)给「数据干不干净」收了官------15 条规则把脏数据挡在入库前。到这儿,S3 的核心数据底座全部就位:日线(S3-002)、复权(S3-003)、本地库(S3-004~006)、分钟(S3-007)、财务 PIT(S3-008)、基本面(S3-009)、股票池(S3-010)、指数成分(S3-011)、质检(S3-012)。
接下来进入 M5 特殊资产。 第一类是可转债 ------它和 ETF 一样是散户少有的 T+0 品种 :10 张一整手、千元级门槛 ,正股暴跌还有债底垫着(债底不等于保本,第二节算细账)。但它的数据不能塞进股票行情库 ------转债的价格被转股价、转股溢价率、强赎 / 回售 / 下修条款牵着走,schema 和股票完全不同,必须独立建表。
把两个反常识事实先摆出来:
- 双低榜首可能是「假」的 :东财
bond_zh_cov的债现价列有 100.0 占位值(实测 748/1059 行)------凡「最后交易日无有效报价」的债,债价被填成面值 100。你照它算双低,算出来的是坏数字(详见第四节); - 双低榜首可能是「不能碰」的 :就算价是真的,排最前面的也常是退债 / 低评级 / 已公告强赎 的风险债(实测 naive TOP5 里 4 只是风险债)。
这一篇讲清这两件事怎么在数据层挡掉------选债前先过三道闸:能交易 → 排雷 → 再排序。
进数据层之前,先把两件基础讲明白:转债凭什么适合散户 (第二节,附实测的只数、门槛、评级账)、双低到底在赌什么(第四节)------不然后面那些公式和闸门,你只是照抄。
🔴 本篇 vs S1(必须先分清) :S1 是从认知视角 讲「转债是什么、打新怎么玩」。本篇是数据工程视角 ------聚焦「转债的价格 / 溢价率 / 条款数据怎么抓、怎么归一化、怎么落库」,不重复「转债是什么」。一句话:S1 教你认识转债,S3-013 教你把转债变成可回测的工程数据。
🟡 本篇 vs S7-104 / S7-105(补数据底座) :那两篇是策略篇 (双低怎么选、强赎博弈怎么玩)。本篇是它们前面的数据地基 ------没有本篇落好的
data/cb/库,策略篇无从跑起。本篇只做数据,不碰买卖逻辑。🟡 本篇 vs S3-002(同为 AkShare,不同资产) :S3-002 用 AkShare 抓的是股票日线 / 财务 / 龙虎榜 的 raw 管道;本篇抓的是转债的转股价 + 条款,schema 完全不同,但「限速 / 重试 / 归一化 / 落本地」这套工程纪律一脉相承。
这一篇按「转债是个什么资产、为什么适合散户 → 为什么必须单独建表 → 实测 AkShare 三接口能给什么 → 双低在赌什么 + 三个核心指标自己算(含把价验真)→ 强赎条款从『触发比』到『状态』 → 真实数据实测 + 双低选债的三道闸 → 落到代码 → 散户怎么用 → 速查表」走。
【本篇取数地图】
| 本篇用到的数据 | 指定源与接口 | 为什么是它 |
|---|---|---|
| 转债当日全市场快照(转股价 / 转股价值 / 债现价 / 转股溢价率 / 正股价 / 评级 / 发行规模) | AkShare bond_zh_cov()(东财) |
免注册、免 token、免 cookie,一次给全市场当日快照(实测 1059 行) |
| 强赎条款(触发比 / 触发价 / 天计数 / 条款原文 / 强赎状态) | AkShare bond_cb_redeem_jsl()(集思录) |
免 cookie,实测 319 只,直接给权威强赎字段(触发价 / 状态) |
| 单券历史日线(回测用) | AkShare bond_zh_hs_cov_daily(symbol)(新浪) |
免费给转债日线(金田转债实测 1329 行) |
| 双低 / 转股溢价率 / 触发价等派生指标 | 本地查询层自己算 | 源只给原始字段,派生指标查询层重算,避免口径漂移(详见第四节) |
单源定案 :可转债(M5)主源 AkShare ------价格类字段固定走东财 bond_zh_cov,条款类字段固定走集思录 bond_cb_redeem_jsl,两者都经 AkShare 封装、免 token 免 cookie,以 债券代码 为连接键,同一字段不跨源合并 (S3-001 §5.5 纪律)。不引入 Tushare 实时取数(免费档积分受限,且转债非其强项)。
🔴 源可信度分级(本篇实测新增) :同一张
bond_zh_cov里,转股溢价率列可信 (用真实成交价算),债现价列有 100.0 占位 ("最后交易日无有效报价")。两列打架时以转股溢价率反推真实价 ,并用新浪bond_zh_hs_cov_daily的最后交易日 + 收盘价做第三方定案------这正是全系列「单源定案、跨源只对拍」纪律的一次实战。
📖 前置知识
- 已跟完 S3-012 (数据质量校验)------本篇的「派生指标自己重算」就是它的质检思想在转债上的延续;也建议先看 S3-011(指数成分,同样的「后缀归一化 + 落库纪律」);
- 转债是「一张债 + 一张转股期权」,交易规则和股票不同(T+0、10 张一整手)------第二节用实测数字展开;
- 会用 Python,环境用 conda
ashare:pip install akshare pandas pyarrow(落 Parquet 必须pyarrow)。
一、先说结论
这一篇只有一句话内核:转债数据 = 东财给当日价格快照 + 集思录给强赎条款,派生指标(转股价值 / 溢价率 / 双低 / 触发价)一律自己算;选债前先过三道闸(能交易 → 排雷 → 排序),否则双低榜第一名可能是坏价造的假数字,或者一张不能碰的债。 先看红线表:
| 🔴 红线 | 原因(一句话) | 正确做法 | 详见 |
|---|---|---|---|
直接拿源里的 债现价 算双低 |
东财 债现价 有 100.0 占位值(实测 748/1059 行),算出来是假双低 |
用源的 转股溢价率 反推真实价 + 打 has_quote,再用第三方日线定案 |
第四、六节 |
| 双低榜首直接当买点 | 榜首席位常是退债 / 低评级 / 已公告强赎的风险债,双低再低也不能碰 | 排序前先 flag_risk 排雷(退债 / 低评级 / 强赎 / 深负溢价) |
第六节 |
| 把双低的「低」当成「安全」 | 双低相同 ≠ 两张债相同:同样双低 114 可能是「101 元 + 13% 溢价」(跌不动)也可能是「117 元 + 0.6% 溢价」(跟涨狠);跌破 100 元的两只债也都不是「打折货」 | 双低只当排序键,构成要拆开看;「价格低」先问是期权归零还是兑付打折 | 第四节 |
| 把溢价率当小数加进双低 | 溢价率是百分比数字(3.36 = 3.36%),当 0.0336 加会差 100 倍 | 双低 = 债现价 + 转股溢价率(数字直接加) | 第四节 |
| 盲信源给的转股价值 / 溢价率 | 源口径可能错 / 滞后 | 用正股价 / 转股价 / 债现价自己重算校验(但先验价真不真) | 第四节 |
| 拿「申购中」转债(债现价 = 100)做双低 | 未上市无流动性、价格失真 | is_listed 过滤(上市时间非空) |
第四节 |
| 在休市日跑双低榜 | 无行情债大面积占位 100,双低全失真 | 交易日盘后跑 | 第八节 |
| 强赎条款硬抄 130% / 70% | 个券触发比可能不同(如 120%);且「触发价」不等于「已触发」 | 触发比取集思录真实值;状态看「强赎天计数 + 强赎状态」 | 第五节 |
裸调 ak.bond_zh_cov 散落全项目 |
上游改版 / 限流,且你不知道它只给「当日」 | 收口到 CbFetcher(本篇) |
第七节 |
这九条分别在第四、五、六、七节展开;跑代码出问题,回第九节速查表对号入座。
二、转债是个什么资产:为什么适合散户,又为什么要单独建表
2.1 一张转债 = 一张债 + 一张转股期权
转债的面值是 100 元。买它,你等于同时握着两样东西:
- 一张债:到期还本付息------正股跌穿地板,它还有债底托着;
- 一张转股期权 :按约定的转股价,把这张债换成发行公司的股票。
所以转债的价格由「纯债价值 + 转股期权价值」两块组成,而期权那块完全由转股价和正股价格决定------正股涨,期权值钱,转债跟着涨;正股跌,期权变废纸,债底还在。这就是「下有底、上不封顶」这句话的来路。
本篇要算的三个指标,全都从这两块长出来:
| 指标 | 看哪一块 | 一句话 |
|---|---|---|
| 转股价值 | 期权 | 一张转债(面值 100)换成股票,值多少钱 |
| 转股溢价率 | 期权 | 转债比「直接买正股」贵了多少 |
| 双低 | 两块都要 | 价格低(偏债性)+ 溢价率低(偏股性) |
2.2 它适合散户的地方(附实测账)
| 特点 | 实测(2026-10-03 全市场快照) | 对散户意味着什么 |
|---|---|---|
| 10 张一整手,门槛低 | 一手 = 10 张(面值口径 1000 元);当日可算双低的 311 只里,一手金额中位 1300 元 、P75 1469 元 | 单只债的入场门槛在千元级,同一笔钱够铺好几只 |
| T+0 | 交易规则,和 ETF 同款 | 当天能反复进出,不像股票 T+1 被锁一天 |
| 数量够分散 | 已上市 1051 只 | 数量级够铺开,不用挤在一两只上 |
| 评级整体不差 | 全量 1059 只里:AA− 及以上 798 只(75.4%) ,A+~BBB− 239 只 ,BB+ 及以下只有 20 只 | 主流是高评级债,不是「垃圾债市场」 |
评级那一条尤其值得留意:转债市场的评级结构比一般人想的干净 。四分之三的债在 AA− 及以上,真正落到投机级的只有 20 只------但这个数字不是零,这就是下一节要说的。
2.3 但「下有底」不等于保本
债底的前提是公司能还钱。同样在这张快照里,债价跌破 100 元的只有 2 只------把构成摊开,就知道这种「便宜」是怎么来的:
| 债 | 债价 | 转股价值 | 转股溢价率 | 评级 | 「便宜」是怎么来的 |
|---|---|---|---|---|---|
| 三房转债 | 73.449 | 45.36 | +61.91% | A− | 正股跌到 1.37 元、期权几乎归零,债价又掉到面值以下 → 期权折价叠上兑付打折 |
| 东时转债 | 81.438 | 69.23 | +17.63% | C | 最后交易日停在 2026-04-02(半年无行情),东财仍留着末日残值 |
两只见不到「债底保护」的影子:一只的期权已经归零、价格也跌穿面值,一只连行情都没了。双低这个数字认不出它们的区别,评级和「当天有没有行情」才认得出------这就是第六节那三道闸存在的理由,也是第四节要把「价格低」和「安全」分开讲的原因。
2.4 所以数据必须单独建表
回到工程:
- schema 完全不同 :股票表是
开高低收量额;转债表得带转股价 / 转股价值 / 转股溢价率 / 强赎触发价 / 强赎触发比例------这些字段股票表里没有; - 条款是「事件型」数据 :转股价平时不动,一旦「下修」就剧变;强赎一旦公告,转债就进入退市倒计时(按面值 + 利息赎回,高价买的人巨亏)。变化稀,但影响致命。
所以转债独立建表、独立抓取、独立落库 ,落点 data/cb/<YYYY>/<YYYYMMDD>.parquet。
💡 和 S3-012 同一个套路:免费源只给「今天」 。东财
bond_zh_cov给你的是此刻快照;你要「2019-03-15 某只转债的双低」,得靠 Tusharecb_daily/cb_basic的历史序列重构(本篇聚焦 AkShare 当日地基,历史序列留作扩展)。
三、AkShare 可转债接口实测:能给什么、不能给什么
动手前先看清能力边界。我在 conda ashare(akshare 1.18.81)里逐个联网试跑,结论如下:
| 接口 | 源 | 免 cookie | 实测行数 | 给什么 | 选它? |
|---|---|---|---|---|---|
bond_zh_cov() |
东财 | ✅ | 1059 | 债券代码 / 简称 / 正股代码 / 正股价 / 转股价 / 转股价值 / 债现价 / 转股溢价率 / 信用评级 / 发行规模 / 上市时间 | ✅ 主源(价格类) |
bond_cb_redeem_jsl() |
集思录 | ✅ | 319 | 转股价 / 强赎触发比 / 强赎触发价 / 强赎天计数 / 强赎条款原文 / 强赎状态 | ✅ 主源(条款类) |
bond_zh_hs_cov_daily(symbol) |
新浪 | ✅ | 1329(金田转债) | 单券历史日线 date/open/high/low/close/volume |
✅ 回测用 |
bond_cb_jsl(cookie) |
集思录 | ❌ 需 cookie | 30(无 cookie 有限行) | 含双低 / 剩余年限 / 到期税前收益 | 可选增强 |
bond_cb_adj_logs_jsl(symbol) |
集思录 | ✅ | 单券 | 下修记录 | 按需 |
bond_cov_comparison() |
东财 | ✅ | --- | 比价表(含纯债价值 / 强赎触发价 / 回售触发价) | ⚠️ 本次实测连接被重置(不稳),不选它做主源 |
bond_convert_rank / bond_cov_compute |
--- | --- | --- | 1.18.81 已移除 | ❌ 别再用 |
三个要点:
- 价格类字段主源 =
bond_zh_cov:无参、免 cookie、稳定返回全市场当日快照。但它的「已上市」名单里混着已摘牌 / 已退市 / 已公告强赎的债 ,而且------🔴债现价列有 100.0 占位值 (实测 748/1059 行):凡是「最后交易日无有效报价」的债(停牌 / 已摘牌 / 已退市),东财把债现价填成面值 100,而同一行的转股溢价率却是用上一个有行情日的真实价算的。两列口径不同,直接相加就是假双低------第四节展开; - 条款类字段主源 =
bond_cb_redeem_jsl:免 cookie 就给了权威的强赎触发比 / 触发价 / 天计数 / 状态,比在本地「硬编 130% 折算」准得多; bond_cov_comparison本次实测连接被重置 (东财限流特性),所以不选它做价格主源 ------CbFetcher只带「重试 + 退避」,选一个稳的接口比选一个字段多的接口更重要。
四、三个核心指标自己算------但先验价真不真
4.1 三个公式
东财源里有「转股价值 / 转股溢价率」两列,但能自己算的绝不盲信源------口径可能漂移,重算本身也是一次免费质检(呼应 S3-012)。
python
# 转股价值: 一张转债(面值 100)能换成多少市值的正股
conv_value = stock_price / conv_price * 100 # 例: 39.09 / 44.82 × 100 = 87.2155
# 转股溢价率: 转债比「直接买正股」贵多少 (百分比)
conv_premium = (bond_price / conv_value - 1) * 100 # 例: (100 / 87.2155 - 1) × 100 = 14.66 (%)
# 双低: 价格低 + 溢价率低 = 攻守兼备 (双低策略核心指标)
double_low = bond_price + conv_premium # 例: 100 + 3.36 = 103.36
⚠️ 双低最常见的坑 :把溢价率当小数(0.0336)加进债现价,结果双低 = 100.0336,和正确值 103.36 差 100 倍。源给的溢价率已经是百分比数字,直接加。
4.2 双低到底在赌什么
「双低」是转债圈的叫法,指价格低 + 转股溢价率低 。这两个「低」要的其实是相反的两样东西:
- 价格低 → 债性强:价格越靠近面值 100,跌下去越有债底垫着------代价是正股涨它也跟不动;
- 溢价率低 → 股性强:溢价率越接近 0,正股涨 1% 它就能跟 1%------代价是往下也没了保护,跌起来跟股票一样狠。
两个都要,就是在用「价格」买债底、用「溢价率」买跟涨,这就是双低策略的全部内容。公式里把它们直接相加只是记账:两者都「越小越好」,圈内约定俗成这么记(量纲其实不同,一个是元、一个是百分点,相加只用来排序,没有绝对含义)。
但双低数字相同,不等于两张债相同。 实测(可算双低 311 只,双低落在 105~125 的只有 7 只):
| 债 | 双低 | = 债价 | + 转股溢价率 | 偏哪一边 |
|---|---|---|---|---|
| 闻泰转债 | 114.69 | 101.39 | +13.30% | 偏债性 |
| 大参转债 | 113.63 | 110.90 | +2.73% | 中间 |
| 金田转债 | 114.39 | 114.73 | −0.34% | 偏股性 |
| 上银转债 | 118.07 | 117.47 | +0.60% | 偏股性 |
四只债的双低挤在 113~118 这 5 个点里,构成却横跨两类:101 元 + 13% 溢价 的债跌不动也涨不动,117 元 + 0.6% 溢价 的债跟涨能力接近股票、往下也没了债底。所以双低只是初筛的排序键------选到具体哪一只之前,必须把构成拆开看。
顺带算一笔门槛账(同样在这 311 只里):
| 双低门槛 | 命中只数 | 占比 |
|---|---|---|
| < 110 | 2 | 0.6% |
| < 120 | 8 | 2.6% |
| < 130(圈内常用的分界线) | 13 | 4.2% |
| < 140 | 27 | 8.7% |
| < 150 | 37 | 11.9% |
双低榜不是「便宜货清单」:门槛卡到 130,全市场只剩 13 只可选------可选越少,越容易被逼着集中在少数几只债上。
最后一个反直觉的:「低价」和「低溢价」经常不是一回事 。311 只里溢价率最高的三只是福莱转债 +423.91% 、立高转债 +343.30% 、科沃转债 +286.02%------正股跌残了,转债价格没跟着跌到位,溢价率就会飙到几百个百分点。这种债的双低值很高、天然排在榜外;但如果你只盯「价格低」不看溢价率,很容易把它们当便宜货捡走。
4.3 但「自己算」的前提是「价是真的」
上面三个公式全部依赖 bond_price(债现价)。问题就出在这儿:东财 bond_zh_cov 的 债现价 列会用 100.0(面值)给"最后交易日无有效报价"的债占位 ,实测 748/1059 行 。而同一行的 转股溢价率 是用上一个有行情日 的真实价算的------两列口径不同。
把占位价和真实溢价率直接相加,得到的是假双低。实测(2026-10-03):
| 债 | 东财 债现价 |
东财 转股溢价率 |
照抄算出的双低 | 新浪日线铁证 | 真实双低 |
|---|---|---|---|---|---|
| 胜蓝转02 (123258) | 100.0(占位) | −1.27% | 45.37(假) | 2026-09-29 收 217.6 | 216.33 |
| 鸿路转债 (128134) | 100.0(占位) | 4.84% | 95.26(假) | 2026-09-28 收 110.053 | 114.90 |
| 中装转2 (127033) | 100.0(占位) | 14.50% | 114.50(假) | 2025-09-03(一年多前) | 已摘牌,不该入榜 |
修法 :债现价 == 100 且与源的 转股溢价率 不自洽时,判为占位坏值,用溢价率反推真实价------
python
implied = conv_value * (1 + premium_src / 100) # 由源溢价率反推的真实价
placeholder = (bond_price == 100) & (abs(implied - 100) > 1) # 100 占位且反推价明显偏离
bond_price = implied if placeholder else bond_price # 占位 -> 用反推价修正
has_quote = not placeholder # 该债当日无有效报价, 不得进榜
这里有个通用套路 :源里两个本该自洽的字段打架时,不要猜哪个对------去第三方源(这里是新浪日线)一票定案。这是 S3-001 §5.5「单源定案、跨源只对拍」纪律的一次实战。
🔴 注意一个细节 :
债现价 == 100不能无条件判占位。未上市新债 的债现价就是面值 100(合法),真在 100 元附近交易 的债也是 100.0。所以判据是「100 且 与源溢价率反推价偏离 > 1 元」------实测 中仑转债(债现价 100、溢价率 3.36%、反推价 99.996)不会被误判。
4.4 排雷与排序是两件事
normalize_realtime 只负责把价修对、把派生指标算对。「这债能不能买」分两级 :能不能交易(is_listed + has_quote)、是不是风险债(flag_risk)。两级都过,才轮到按双低排序 ------这就是 pick_double_low 的三道闸,见第八节。
normalize_realtime 对每个派生指标都重算一遍,并在 self-test 里断言「重算值 = 源值」------源对得上说明口径一致;对不上,说明源脏了,先查源。
五、强赎条款:从「触发比」到「状态」,别停在折算
强赎是转债最要命的条款。上一节你自己算的「转股价 × 130%」只是触发价 ,不是是否已经触发。二者差得非常远,中间隔着一个「连续 15 / 30 个交易日」的窗口和一份公司公告。
集思录 bond_cb_redeem_jsl 直接把这段权威信息给你:
| 字段 | 含义 | 实测样例(金田转债 113046) |
|---|---|---|
转股价 |
当前转股价 | 10.18 |
强赎触发比 |
触发比例(%) | 130 |
强赎触发价 |
= 转股价 × 触发比 / 100 | 13.234 |
强赎天计数 |
满足天数 / 需满足天数 | 窗口 |
强赎条款 |
条款原文 | 连续 30 个交易日中至少 15 日收盘价 ≥ 当前转股价的 130% |
强赎状态 |
处置状态 | 公告要强赎 |
一个可验证的硬事实 :把 319 行的 强赎触发价 和「转股价 × 触发比 / 100」逐行对拍------319/319 全部吻合(见第六节实测)。这说明两件事:① 触发价的本质就是「转股价 × 触发比」,你自己也能算;② 集思录这张表的自洽性经得起检验。
强赎状态实测只有四档 :空白(272,未触发 / 无信息)、公告不强赎 (38)、已公告强赎 (8)、公告要强赎 (1)。前两档安全,后两档要命------已进入强赎的债,价格会在强赎价附近收敛,拿双低买进去就是接盘。
🔴 回售 / 下修的公式 留作扩展:回售触发价 = 转股价 × 回售触发比(常见 70%),下修触发价 = 转股价 × 下修触发比(常见 85% / 90%)。比例同样取集思录真实值,别硬编。本篇代码里
PUT_RATIO_DEFAULT/DOWN_RATIO_DEFAULT就是这个兜底。
六、真实数据实测:双低榜首的陷阱
这一节全部来自 2026-10-03 本机实测(conda ashare:akshare 1.18.81 / pandas 3.0.5),跑的是真实联网数据,不是合成 fixture。
6.1 三接口实测 + 触发价公式对拍
东财 bond_zh_cov : 1059 行, 已上市 1051 只, 有有效报价 318 只, 债现价占位修正 4 只
集思录 bond_cb_redeem_jsl : 319 行
强赎状态分布: {'': 272, '公告不强赎': 38, '已公告强赎': 8, '公告要强赎': 1}
触发价公式对拍: 319 行可比, 吻合 319/319
可算双低(已上市且当日有有效报价): 311 只, 双低 99.07 ~ 774.24
注意第一行:1059 行里,只有 311 只「已上市且当日有有效报价」能算双低 ------不是"1051 只已上市"全都在算。剩下的行要么没转股价(还没进转股期),要么 债现价 是 100 占位。报「双低区间」时必须带上这个分母,否则读者会以为全市场都参与了。
双低区间 99.07 ~ 774.24------下限 99.07 是「低价 + 低溢价」的真债,上限 774 是极端高溢价妖债。
6.2 双低榜首的陷阱:naive 排序 vs 三道闸后
先过第一道闸(已上市 + 有有效报价),只看双低升序取前 5------这就是「不排雷」的样子:
naive 双低 TOP5(仅剔除未上市/无报价):
113575.SH 东时转债 双低= 99.07 溢价= 17.63% 评级=C 风险=True
118027.SH 宏图转债 双低= 101.71 溢价= 0.16% 评级=CC 风险=True
113605.SH 大参转债 双低= 113.63 溢价= 2.73% 评级=AA 风险=True
113046.SH 金田转债 双低= 114.39 溢价= -0.34% 评级=AA+ 风险=True
110081.SH 闻泰转债 双低= 114.69 溢价= 13.30% 评级=BBB- 风险=False
TOP5 里有 4 只被判风险:东时转债(评级 C)、宏图转债(评级 CC)、大参转债(已进入强赎流程)、金田转债(已公告强赎)。把风险债排掉,再过一遍:
过三道闸(已上市 + 有报价 + 排雷)后 TOP5:
110081.SH 闻泰转债 双低= 114.69 溢价= 13.30% 评级=BBB-
127061.SZ 美锦转债 双低= 114.77 溢价= 6.77% 评级=A
113043.SH 财通转债 双低= 116.40 溢价= 7.06% 评级=AAA
113042.SH 上银转债 双低= 118.07 溢价= 0.60% 评级=AAA
113610.SH 灵康转债 双低= 126.53 溢价= 5.32% 评级=A-
三道闸后 TOP5 全部是 BBB− / A / AAA 级。 这就是本篇最值钱的一句话:双低策略的第一步不是排序,是过闸;不过闸,你的双低榜单子开出来可能是几张「不能碰的债」。
🔴 三道闸(
is_listed&has_quote&~is_risky) :① 能交易 ------
is_listed(已上市)+has_quote(当日有有效报价,债现价不是 100 占位);② 不是风险债 (
flag_risk)------名称含「退」=退债;评级 ∈ {C, CC, CCC, B, B−};强赎状态 ∈ {已公告强赎, 公告要强赎};转股溢价率 ≤ −20%=深度负溢价(强赎 / 套利中,双低失真);③ 过了前两道,才按双低升序取头部。
⚠️ 一个诚实的边界 :东财这张快照识别不出「已停牌 / 已摘牌、但东财仍保留最后成交价」的债 ------实测 东时转债最后交易日是 2026-04-02 (半年无行情),东财却仍给它末日收盘价 81.438。这类债当前靠排雷闸兜住(东时是 C 级);要想彻底干净,得逐只对拍新浪日线的最后交易日,或改用带停牌 / 摘牌标记的源。本篇不越步。
6.3 离线自检:6 组断言
S3-013 可转债数据体系 · 离线自检(6 组断言, 无需网络)
[映射] ✅ 债券(11x→.SH / 12x→.SZ)与正股后缀归一化正确
[公式] ✅ 转股价值/溢价率/双低 重算与源一致(双低=债现价+溢价率)
[条款] ✅ 强赎触发价=转股价×触发比/100,状态/触发信号正确
[报价] ✅ 债现价==100 占位识别 + 源溢价率反推修正(假 45.37 -> 真 216.33)
[落库] ✅ 年分区快照 save/load/时点还原正确
[排雷] ✅ 退债/无报价被三道闸挡在榜外, 去雷后榜首换人
🎉 self-test 全部通过 (6 组断言; 联网取数见 --real)
--demo 则用一份合成快照(含一只退债 + 一只占位债)演示「不设闸 vs 过三道闸」,让你在离线下就能看到榜首换人。
七、可运行代码:CbFetcher + 归一化 + 落库
本篇全部逻辑在 src/s3_013_convertible_bond.py(脚本在代码仓库 E:\ashare-quant\src/,已实跑)。核心是「一个取数封装 + 两组归一化(含报价校验)+ 一个条款合并 + 一个排雷 + 两道选债闸 + 一套本地快照」。
7.1 公开 API(骨架)
python
from s3_013_convertible_bond import (
CbFetcher, normalize_realtime, normalize_redeem, merge_clause, flag_risk,
rank_double_low, pick_double_low, save_snapshot, snapshot_on_date,
)
f = CbFetcher(min_interval=0.5, max_retries=3, backoff=2.0) # 限速 + 退避重试
rt = normalize_realtime(f.fetch_realtime(), as_of="2026-10-03") # 东财价格类(含报价校验/修正)
red = normalize_redeem(f.fetch_redeem()) # 集思录条款类
df = flag_risk(merge_clause(rt, red)) # 合并 + 排雷
rank_double_low(df) # 双低升序(仅过"能交易"闸), 对照用
pick_double_low(df, topn=20) # 三道闸后选债: 能交易 -> 排雷 -> 双低升序
save_snapshot(df, "2026-10-03") # -> data/cb/2026/20261003.parquet
hist = snapshot_on_date("2026-09-30") # 还原 ≤ 该日最近一次快照
# 单券历史日线(回测用, 也是校验"债现价"是否占位的第三方定案工具)
daily = f.fetch_daily("sh113046") # 新浪, date/open/high/low/close/volume
7.2 后缀归一化(债券和股票规则不同)
转债代码 11x / 113 → .SH ,12x / 123 → .SZ(和股票前缀规则不同,必须单独处理);正股代码沿用 S3-002 日线的后缀口径:
python
def to_bond_symbol(code: str) -> str:
code = str(code).strip()
if code[:2] in ("11", "10"): # 110 / 111 / 113 沪市
return f"{code}.SH"
if code[:2] == "12": # 123 / 127 / 128 深市
return f"{code}.SZ"
return code
7.3 怎么接进双低策略(只做数据,买卖逻辑留给 S7-104)
python
# 双低选债: 三道闸(能交易 + 排雷) + 按双低升序
candidates = pick_double_low(df, topn=20)
# 等价于:
# candidates = (df[df["is_listed"] & df["has_quote"] & ~df["is_risky"]]
# .sort_values("double_low").head(20))
🔴 下单前先过「整手」这一关 :转债一手 = 10 张,榜上的价不能直接当成交金额------双低榜首那种 118 元上下的债,一手就是 1180 元上下。选出来的债下单前按 10 张取整,别把「选出来的价」当成「能下手的价」。这一步留给回测与实盘工程,本篇只把干净数据备好。
八、散户怎么用:先能交易,再排雷,最后才排序
转债数据不是「抓回来存盘」就完事,它在你的流水线里排位很清楚------三道闸的优先级:
- 能交易(第一道,最高优先级) :先剔掉「当日无有效报价」和「未上市」的债。这一步的价值在于挡掉假数字 而不是挡风险------东财
债现价的 100 占位会让一只债的双低凭空变成 45(第四节)。用坏价排出来的榜,后面怎么排都是错的; - 排雷(第二道) :
flag_risk滤掉退债 / 低评级(C、CC、CCC、B、B−)/ 已(要)公告强赎 / 深度负溢价。第一节红线表里随便中一条(双低榜首是退债 / 已公告强赎当买点 / 溢价率当小数)都足以让结论反转。第六节实测:naive TOP5 里 4 只是风险债; - 粗筛与排序(第三道) :过了前两道,才按双低升序取头部;可再叠加「评级 ≥ AA、剩余规模 > 1 亿、剩余年限 > 1 年」等流动性 / 久期条件(字段来自集思录
bond_cb_jsl/bond_zh_cov)。注意双低只是个排序键------同样是 114 的两只债,可能是「101 元 + 13% 溢价」(跌不动)也可能是「117 元 + 0.6% 溢价」(跟涨狠),入围后还要把构成拆开,再按你想偏债还是偏股来定(第四节); - 归因(数据 ≠ 策略) :策略回测异常时,先怀疑数据 ------第九节速查表就是归因手册。双低异常先看是不是踩了 100 占位 / 是退债 / 深负溢价,条款异常先看
强赎状态。
⚠️ 别在非交易日跑这份榜 :
债现价的 100 占位在休市日会大面积出现 (本篇实测日 2026-10-03 是国庆假期,占位 748/1059)。当天没行情的债,面值 100 会让它的双低彻底失真。要「今天的双低」,就在交易日盘后跑。
⚠️ 别把「当日快照」当历史 :本表是当天快照,强赎状态、双低每天都会变。要做历史回测,用bond_zh_hs_cov_daily拉单券日线,或用 Tusharecb_daily/cb_basic重建历史时点------当日快照只够「今天选债」。
九、踩坑速查表
| 现象 | 后果 | 正确做法 | 出处 |
|---|---|---|---|
直接拿源 债现价 算双低 |
债现价 有 100 占位,双低是假值(胜蓝转02 假 45.37 / 真 216.33) |
用源 转股溢价率 反推真实价 + 打 has_quote,第三方日线定案 |
第四节 |
| 双低榜首席直接买 | 榜首常是退债 / 低评级 / 已公告强赎的风险债 | 排序前 flag_risk 排雷 |
第六节 |
| 把双低当「便宜」的同义词 | 同为双低 114,可能是 101 元+13% 溢价,也可能是 117 元+0.6% 溢价;债价 73.449 的三房转债溢价率反而高达 61.91% | 双低只当排序键,构成拆开看;「价格低」≠「安全」 | 第四节 |
| 溢价率当小数加双低 | 差 100 倍(100.0336 vs 103.36) | 溢价率当百分比数字直接加 | 第四节 |
| 只信「自己重算」不看价真不真 | 三个公式全依赖 债现价,价坏了算得再准也错 |
重算前先校验报价(占位判据) | 第四节 |
| 拿「申购中」债(价 = 100)算双低 | 未上市无流动性,价格失真 | is_listed 过滤 |
第一节 |
| 在休市日跑双低榜 | 无行情债大面积占位 100,双低全失真 | 交易日盘后跑 | 第八节 |
| 强赎触发比硬编 130% | 个券可能是 120% / 80%,触发价算错 | 取集思录 强赎触发比 真实值 |
第五节 |
| 把「触发价」当「已触发」 | 触发价只是阈值,中间还隔 15/30 日窗口 + 公告 | 看 强赎天计数 + 强赎状态 |
第五节 |
用 bond_cov_comparison 做价格主源 |
本次实测连接被重置(不稳) | 价格主源固定 bond_zh_cov(稳) |
第三节 |
裸调 ak.bond_zh_cov |
上游改版 / 限流,散落无法维护 | 收口到 CbFetcher(限速 + 退避) |
第七节 |
十、本篇小结 & 下篇预告
小结 :转债 = 一张债 + 一张转股期权,「下有底、上不封顶」;一手 10 张、T+0,全量 1059 只里 AA− 及以上占 75.4% ------但「有底」不等于保本(BB+ 及以下 20 只,跌破 100 元的 2 只一个是期权归零、一个半年无行情)。这一篇给「可转债数据地基」打好了,给 S7-104(双低)/ S7-105(强赎博弈)备好弹药:
- 主源 AkShare(单源定案) :价格类走东财
bond_zh_cov(实测 1059 行 / 已上市 1051 只),条款类走集思录bond_cb_redeem_jsl(实测 319 行),历史日线走新浪bond_zh_hs_cov_daily------全免 token / 免 cookie; - 先把价验真 :东财
债现价有 100.0 占位坑 (实测 748/1059 行),占位行用源的转股溢价率反推真实价修正并打has_quote------实测只有 311 只 「已上市且当日有有效报价」能算双低,区间 99.07 ~ 774.24; - 三个核心指标自己算 :转股价值 = 正股价 / 转股价 × 100、溢价率 = (债现价 / 转股价值 − 1) × 100、双低 = 债现价 + 溢价率(溢价率当百分比数字直接加)。双低的两个「低」含义相反(价格低 = 偏债性、溢价率低 = 偏股性),相加只为排序------实测同为双低 114 的债可以差出一整个类型(101 元+13% 溢价 vs 117 元+0.6% 溢价),311 只里双低 < 130 的只剩 13 只;
- 强赎条款看状态 :触发价 = 转股价 × 触发比 / 100(实测对拍 319/319 吻合);真正的风险信号是
强赎状态(已公告强赎 8 / 公告要强赎 1); - 双低的正确姿势是「过三道闸」 :能交易 → 排雷 → 排序。实测 naive 双低 TOP5 有 4 只 风险债,三道闸后 TOP5 全为 BBB− / A / AAA 级(闻泰 / 美锦 / 财通 / 上银 / 灵康)------第一步不是排序,是过闸。
你现在的装备:S3-002 日线 + S3-003 复权 + S3-004~006 本地库 + S3-007 分钟 + S3-008 财务 PIT + S3-009 基本面 + S3-010 股票池 + S3-011 指数成分 + S3-012 质检 + 本篇的转债数据 。M5 特殊资产开了个头------转债之后,还有 ETF 和龙虎榜。
下一篇预告 :专栏 S3 的第十四篇《ETF 数据与实时折溢价计算(附代码)》。你会看到另一个 T+0 主战场资产------ETF 的 IOPV、折溢价的算法与落库。它会和转债殊途同归:免费拿当前、后缀要齐、派生指标自己算、先把价验真。带上本篇「先过闸再排序」的觉悟,我们下篇继续。
附:可运行脚本(本篇全部代码)
本篇交付文件(脚本在代码仓库 E:\ashare-quant\src/,已实跑):
| 文件 | 作用 |
|---|---|
src/s3_013_convertible_bond.py |
可转债数据工程封装:CbFetcher(东财 bond_zh_cov + 集思录 bond_cb_redeem_jsl + 新浪 bond_zh_hs_cov_daily,限速 / 重试)、normalize_realtime / normalize_redeem(列归一化 + 后缀 + 债现价 100 占位识别与源溢价率反推修正 + 转股价值 / 溢价率 / 双低重算 + 条款规范化)、merge_clause(按 bond_code 合并条款)、flag_risk(退债 / 低评级 / 强赎 / 深负溢价排雷)、rank_double_low / pick_double_low(双低选债三道闸)、save_snapshot / snapshot_on_date(本地年分区快照 + 时点还原)+ --self-test(6 组断言全过)+ --real(联网真实落库)+ --demo(离线:占位价 + 退债两类陷阱演示) |
环境准备:
bash
conda activate ashare
pip install akshare pandas pyarrow # 取数 + 落 Parquet 必装(akshare 仅 PyPI 可得)
运行顺序:
bash
cd E:/ashare-quant
python src/s3_013_convertible_bond.py --self-test # 离线验证: 映射/公式/条款/报价/落库/排雷 6 组 ✅
python src/s3_013_convertible_bond.py --real # 联网: 东财快照+集思录强赎 -> 归一化(含报价校验) -> 落 data/cb/
python src/s3_013_convertible_bond.py --demo # 离线: 占位价 + 退债两类陷阱(不设闸 vs 三道闸)
⚠️ 文中数字来自 2026-10-03 本机实测 (conda
ashare:akshare 1.18.81 / pandas 3.0.5)。转债数量、双低区间、强赎状态都会随市场每日变化,请按你自己的运行日期重跑校准。免费接口网络可用性会变(东财偶有限流),CbFetcher已带「重试 3 次 + 指数退避」兜底,换个时段再跑即可。bond_cov_comparison本次实测连接被重置,故不作为主源。强赎判定以集思录「强赎状态」为准,本篇单日触发信号仅作前置参考,不构成任何投资建议。
完整脚本 src/s3_013_convertible_bond.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
s3_013_convertible_bond.py ------ S3-013 可转债数据体系:转股价 / 溢价率 / 强赎条款怎么落库
对应博客:S3-013《可转债数据体系------转股价、溢价率、强赎条款怎么落库(AkShare)》
═══════════════════════════════════════════════════════════════
定位(一句话)
═══════════════════════════════════════════════════════════════
M5 特殊资产第一篇。转债 = 纯债 + 转股期权,schema 与股票不同,必须独立建表。
主源 AkShare(M5 单源定案):
- bond_zh_cov() 东财 · 全市场当日快照(转股价/转股价值/债现价/转股溢价率/评级/规模)
- bond_cb_redeem_jsl() 集思录 · 强赎条款(触发比/触发价/天计数/条款原文/强赎状态)
- bond_zh_hs_cov_daily() 新浪 · 单券历史日线(回测用)
三者均免 token / 免 cookie;不跨源合并同一字段(S3-001 §5.5 纪律)。
═══════════════════════════════════════════════════════════════
实测事实(2026-10-03,conda ashare,akshare 1.18.81)
═══════════════════════════════════════════════════════════════
- 🔴 双低 = 债现价 + 转股溢价率(溢价率是**百分比数字**,如 3.36 表示 3.36%,直接相加);
当小数(0.0336)相加会差 100 倍。
- 🔴 **东财 `债现价` 有 100.0 占位坑**:`债现价 == 100.0` 共 **748/1059** 行,含义是
「最后交易日无有效报价」(停牌 / 已摘牌 / 已退市);而 `转股溢价率` 列是用**上一个
有行情日**的真实价算的 ------ 两列口径不同,直接相加会造出**假双低**:
胜蓝转02 假双低 45.37(占位价 100 + 溢价率 -54.63),真实双低 **216.33**
(新浪 2026-09-29 收盘 217.6);鸿路转债 假 95.26 vs 真实 **114.90**(新浪 09-28 收 110.053)。
-> 双低**只能对当日有有效报价的债算**(实测 **311 只**,区间 **99.07 ~ 774.24**);
占位行的价用源的 `转股溢价率` 反推修正,并打 `has_quote=False`(不可进榜)。
- 🔴 双低榜首席位常被**风险债**占据:311 只有报价债的 naive 双低 TOP5 里 **4 只**被判风险
(东时转债评级 C、宏图转债 CC、大参转债、金田转债 已公告强赎);排雷后 TOP5 全换成
BBB- / A / AAA 级(闻泰 / 美锦 / 财通 / 上银 / 灵康)。
-> 双低筛债**必须先排雷**(退债 / 低评级 / 已公告强赎 / 深度负溢价)。
- 转股价值 = 正股价 / 转股价 × 100;转股溢价率 = (债现价 / 转股价值 - 1) × 100:
莱特转债 39.09 / 44.82 × 100 = 87.2155;(100 / 87.2155 - 1)×100 = 14.66 (与东财源一致)。
- 🔴 强赎触发价 = 转股价 × 强赎触发比 / 100:集思录 `bond_cb_redeem_jsl` 319 行
**对拍 319/319 全部吻合**(如金田转债 10.18 × 130% = 13.234)。
- 强赎状态四档(集思录):空白 272 / 公告不强赎 38 / 已公告强赎 8 / 公告要强赎 1。
═══════════════════════════════════════════════════════════════
用法(conda activate ashare)
═══════════════════════════════════════════════════════════════
python src/s3_013_convertible_bond.py --self-test # 离线:6 组断言(映射/公式/条款/报价/落库/排雷)
python src/s3_013_convertible_bond.py --real # 联网:东财快照 + 集思录强赎 -> 归一化 -> 落库
python src/s3_013_convertible_bond.py --demo # 离线:合成含退债+占位价的快照, 看三道闸前后
"""
from __future__ import annotations
import argparse
import os
import random
import sys
import time
from pathlib import Path
import pandas as pd
# 东财接口内部用 tqdm 打进度条, 会污染"复制即用"的输出 -> 全局关闭(只作用于本进程)
os.environ.setdefault("TQDM_DISABLE", "1")
# ------------------------------------------------------------------ 阈值常量
CALL_RATIO_DEFAULT = 1.30 # 强赎触发比默认 130%(真实值以条款表为准)
PUT_RATIO_DEFAULT = 0.70 # 回售触发比默认 70%
DOWN_RATIO_DEFAULT = 0.90 # 下修触发比默认 90%
RISKY_RATINGS = {"C", "CC", "CCC", "B", "B-"} # 低评级视作风险(仅供参考, 非投资建议)
NO_QUOTE_PRICE = 100.0 # 东财 bond_zh_cov 的"无有效报价"占位价(面值)
PRICE_TOL = 1.0 # 债现价与"源溢价率反推价"偏离超过此值, 判为占位坏值
CANONICAL_COLS = [
"bond_code", "bond_name", "stock_code", "stock_name",
"stock_price", "conv_price", "conv_value", "bond_price", "conv_premium",
"double_low", "has_quote", "price_fixed", "credit_rating", "issue_size", "is_listed",
"as_of", "source",
]
CLAUSE_COLS = ["call_ratio", "call_trigger_price", "call_days", "call_clause",
"call_status", "call_triggered"]
# ------------------------------------------------------------------ 代码后缀
def to_bond_symbol(code: str) -> str:
"""可转债代码补交易所后缀:11x / 113 -> .SH;12x / 123 -> .SZ。"""
code = str(code).strip()
if code[:2] in ("11", "10"):
return f"{code}.SH"
if code[:2] == "12":
return f"{code}.SZ"
return code
def to_stock_symbol(code: str) -> str:
"""正股代码补交易所后缀(与 S3-002 日线同口径)。"""
c = str(code).strip().split(".")[0].zfill(6)
if c[:2] in ("60", "68", "90", "91"):
return f"{c}.SH"
if c[:2] in ("00", "30", "20", "39"):
return f"{c}.SZ"
if c[0] in ("8", "4") or c[:3] == "920":
return f"{c}.BJ"
return c
# ------------------------------------------------------------------ 取数封装
class CbFetcher:
"""可转债取数封装:限速 + 指数退避重试。主源 AkShare,免 token / 免 cookie。"""
def __init__(self, min_interval: float = 0.5, max_retries: int = 3, backoff: float = 2.0):
self.min_interval = min_interval
self.max_retries = max_retries
self.backoff = backoff
self._last = 0.0
def _throttle(self) -> None:
gap = time.time() - self._last
if gap < self.min_interval:
time.sleep(self.min_interval - gap)
def _call(self, fn, *args, **kwargs):
for attempt in range(self.max_retries):
self._throttle()
try:
res = fn(*args, **kwargs)
self._last = time.time()
return res
except Exception:
self._last = time.time()
if attempt == self.max_retries - 1:
raise
time.sleep(self.backoff ** (attempt + 1) + random.uniform(0, 0.3))
def fetch_realtime(self) -> pd.DataFrame:
"""东财全市场当日快照(价格 / 转股价 / 溢价率 / 评级)。"""
import akshare as ak
return self._call(ak.bond_zh_cov)
def fetch_redeem(self) -> pd.DataFrame:
"""集思录强赎条款(触发比 / 触发价 / 天计数 / 条款原文 / 强赎状态)。"""
import akshare as ak
return self._call(ak.bond_cb_redeem_jsl)
def fetch_jsl(self, cookie: str | None = None) -> pd.DataFrame:
"""集思录全字段快照(含双低 / 剩余年限 / 到期税前收益)。无 cookie 时返回有限行。"""
import akshare as ak
return self._call(ak.bond_cb_jsl, cookie=cookie)
def fetch_daily(self, symbol: str) -> pd.DataFrame:
"""新浪单券历史日线(回测用):symbol 形如 'sh113046' / 'sz128039'。"""
import akshare as ak
return self._call(ak.bond_zh_hs_cov_daily, symbol=symbol)
# ------------------------------------------------------------------ 归一化
def normalize_realtime(raw: pd.DataFrame, as_of: str, source: str = "eastmoney") -> pd.DataFrame:
"""bond_zh_cov 原始 df -> 规范 schema(含报价校验与派生指标重算)。
🔴 关键工程点:东财 `债现价` 列用 **100.0(面值)占位**「最后交易日无有效报价」的债
(停牌 / 已摘牌 / 已退市 / 未上市,实测 748/1059);而 `转股溢价率` 列是用**上一个有
行情日**的真实价算的 ------ 两列口径不同。直接 债现价 + 自算溢价率 会造出**假双低**
(实测 胜蓝转02 假 45.37 / 真实 216.33)。这里做三道处理:
① 用源的 `转股溢价率` 反推真实价 implied = 转股价值 ×(1 + 溢价率/100);
② 当 `债现价==100` 且 implied 明显偏离 100 -> 判占位,`bond_price` 取 implied 修正,
并置 `has_quote=False`(该债当日无有效报价,不得进双低榜);
③ 派生指标(溢价率 / 双低)一律用**修正后**的价重算,噪音不出库。
"""
df = pd.DataFrame()
df["bond_code"] = raw["债券代码"].map(to_bond_symbol) if "债券代码" in raw else pd.NA
df["bond_name"] = raw.get("债券简称", pd.NA)
df["stock_code"] = raw["正股代码"].map(to_stock_symbol) if "正股代码" in raw else pd.NA
df["stock_name"] = raw.get("正股简称", pd.NA)
sp = pd.to_numeric(raw.get("正股价"), errors="coerce")
cp = pd.to_numeric(raw.get("转股价"), errors="coerce")
bp = pd.to_numeric(raw.get("债现价"), errors="coerce")
prem_src = pd.to_numeric(raw.get("转股溢价率"), errors="coerce")
conv_value = (sp / cp * 100).round(4)
implied = (conv_value * (1 + prem_src / 100)).round(3) # 由源溢价率反推的真实价
placeholder = (
bp.round(6).eq(NO_QUOTE_PRICE)
& (implied.isna() | ((implied - NO_QUOTE_PRICE).abs() > PRICE_TOL))
)
price = bp.where(~placeholder, implied).round(3) # 占位 -> 用反推价修正
conv_premium = ((price / conv_value) - 1).mul(100).round(2)
df["stock_price"] = sp
df["conv_price"] = cp
df["conv_value"] = conv_value
df["bond_price"] = price
df["conv_premium"] = conv_premium
df["double_low"] = (price + conv_premium).round(2)
df["has_quote"] = bp.notna() & ~placeholder # 当日有有效报价(可进榜)
df["price_fixed"] = placeholder & implied.notna() # 债现价被反推价修正过
df["credit_rating"] = raw.get("信用评级", pd.NA)
df["issue_size"] = raw.get("发行规模", pd.NA)
df["is_listed"] = pd.to_datetime(raw.get("上市时间"), errors="coerce").notna()
df["as_of"] = pd.Timestamp(as_of).strftime("%Y-%m-%d")
df["source"] = source
return df[CANONICAL_COLS].reset_index(drop=True)
def normalize_redeem(raw: pd.DataFrame) -> pd.DataFrame:
"""bond_cb_redeem_jsl 原始 df -> 条款规范 schema。"""
df = pd.DataFrame()
df["bond_code"] = raw["代码"].map(to_bond_symbol) if "代码" in raw else pd.NA
df["call_ratio"] = pd.to_numeric(raw.get("强赎触发比"), errors="coerce") / 100.0
df["conv_price"] = pd.to_numeric(raw.get("转股价"), errors="coerce")
df["call_trigger_price"] = pd.to_numeric(raw.get("强赎触发价"), errors="coerce")
df["call_days"] = raw.get("强赎天计数", pd.NA)
df["call_clause"] = raw.get("强赎条款", pd.NA)
df["call_status"] = raw.get("强赎状态", pd.NA).fillna("").astype(str)
return df[["bond_code", "call_ratio", "conv_price", "call_trigger_price",
"call_days", "call_clause", "call_status"]].reset_index(drop=True)
def merge_clause(rt: pd.DataFrame, red: pd.DataFrame) -> pd.DataFrame:
"""把强赎条款左连到快照(按 bond_code);缺条款的用默认 130% 折算触发价。"""
out = rt.merge(red, on="bond_code", how="left", suffixes=("", "_red"))
out["call_ratio"] = out["call_ratio"].fillna(CALL_RATIO_DEFAULT)
calc = (out["conv_price"] * out["call_ratio"]).round(4)
out["call_trigger_price"] = out["call_trigger_price"].fillna(calc)
out["call_status"] = out["call_status"].fillna("")
out["call_triggered"] = (out["stock_price"] >= out["call_trigger_price"]) & out["conv_price"].notna()
return out
def flag_risk(df: pd.DataFrame) -> pd.DataFrame:
"""排雷:退债 / 低评级 / 已(要)公告强赎 / 深度负溢价。
注意:本函数**只管"这债是不是风险债"**,不管"能不能交易"------可交易性由
`has_quote`(有有效报价) 与 `is_listed`(已上市) 两个标志负责,见 `pick_double_low`。
"""
out = df.copy()
name = out["bond_name"].astype(str)
rating = out["credit_rating"].astype(str)
status = out["call_status"].astype(str)
out["is_risky"] = (
name.str.contains("退") # 退债 / 退市债
| rating.isin(RISKY_RATINGS) # 低评级
| status.isin(["已公告强赎", "公告要强赎"]) # 已进入强赎
| (out["conv_premium"] <= -20) # 深度负溢价: 强赎/套利中, 双低失真
)
return out
def rank_double_low(df: pd.DataFrame, topn: int = 5) -> pd.DataFrame:
"""①可交易(已上市 & 当日有有效报价) -> 双低升序。不排雷,用于对照展示。"""
ok = df[df["is_listed"] & df["has_quote"] & df["double_low"].notna()]
return ok.sort_values("double_low").head(topn)
def pick_double_low(df: pd.DataFrame, topn: int = 5) -> pd.DataFrame:
"""双低选债三道闸:①可交易(已上市 & 有报价) -> ②排雷(~is_risky) -> ③双低升序。"""
return rank_double_low(df[~df["is_risky"]], topn)
# ------------------------------------------------------------------ 本地快照
def _snap_path(as_of: str, root: str) -> Path:
d = pd.Timestamp(as_of)
return Path(root) / f"{d.year:04d}" / f"{d.strftime('%Y%m%d')}.parquet"
def save_snapshot(df: pd.DataFrame, as_of: str, root: str = "data/cb") -> Path:
p = _snap_path(as_of, root)
p.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(p, index=False)
return p
def load_latest(root: str = "data/cb"):
files = sorted(Path(root).rglob("*.parquet"))
if not files:
return None, None
return pd.read_parquet(files[-1]), files[-1]
def snapshot_on_date(date: str, root: str = "data/cb"):
"""还原 ≤ date 最近一次快照(point-in-time 近似,精度=快照频率)。"""
target = pd.Timestamp(date)
best = None
for f in Path(root).rglob("*.parquet"):
d = pd.Timestamp(f.stem)
if d <= target and (best is None or d > best[0]):
best = (d, f)
return pd.read_parquet(best[1]) if best else None
# ================================================================== 合成数据(离线自检)
def _syn_realtime() -> pd.DataFrame:
"""合成快照:覆盖三类情形------真价≈100(不误判) / 债现价 100 占位(要修正) / 正常报价。"""
return pd.DataFrame([
{"债券代码": "123281", "债券简称": "中仑转债", "正股代码": "301565", "正股简称": "中仑新材",
"正股价": 19.62, "转股价": 20.28, "转股价值": 96.7456, "债现价": 100.0, "转股溢价率": 3.36,
"信用评级": "AA-", "发行规模": 10.68, "上市时间": pd.NaT},
{"债券代码": "113046", "债券简称": "金田转债", "正股代码": "601609", "正股简称": "金田股份",
"正股价": 11.72, "转股价": 10.18, "转股价值": 115.13, "债现价": 114.73, "转股溢价率": -0.35,
"信用评级": "AA+", "发行规模": 15.0, "上市时间": "2021-09-27"},
{"债券代码": "404002", "债券简称": "搜特退债", "正股代码": "002503", "正股简称": "搜于特",
"正股价": 10.00, "转股价": 10.00, "转股价值": 100.0, "债现价": 75.0, "转股溢价率": -25.0,
"信用评级": "C", "发行规模": 8.0, "上市时间": "2020-03-12"},
{"债券代码": "123258", "债券简称": "胜蓝转02", "正股代码": "300843", "正股简称": "胜蓝股份",
"正股价": 119.81, "转股价": 54.36, "转股价值": 220.401, "债现价": 100.0, "转股溢价率": -1.27,
"信用评级": "AA-", "发行规模": 3.3, "上市时间": "2025-09-10"},
{"债券代码": "128134", "债券简称": "鸿路转债", "正股代码": "002541", "正股简称": "鸿路钢构",
"正股价": 18.36, "转股价": 17.49, "转股价值": 104.9743, "债现价": 110.053,
"转股溢价率": 4.84, "信用评级": "AA", "发行规模": 18.8, "上市时间": "2020-11-02"},
])
def _syn_redeem() -> pd.DataFrame:
return pd.DataFrame([
{"代码": "113046", "名称": "金田转债", "转股价": 10.18, "强赎触发比": 130,
"强赎触发价": 13.234, "强赎天计数": "15/15 | 30", "强赎条款": "连续30个交易日中15日≥130%",
"强赎状态": "公告要强赎"},
{"代码": "123281", "名称": "中仑转债", "转股价": 20.28, "强赎触发比": 130,
"强赎触发价": 26.364, "强赎天计数": "0/15 | 30", "强赎条款": "连续30个交易日中15日≥130%",
"强赎状态": ""},
])
# ================================================================== 离线自检
def self_test() -> int:
print("=" * 72)
print(" S3-013 可转债数据体系 · 离线自检(6 组断言, 无需网络)")
print("=" * 72)
rt = normalize_realtime(_syn_realtime(), "2026-10-03")
red = normalize_redeem(_syn_redeem())
df = flag_risk(merge_clause(rt, red))
# 1) 映射:债券/正股后缀
assert df.loc[0, "bond_code"] == "123281.SZ", df.loc[0, "bond_code"]
assert df.loc[1, "bond_code"] == "113046.SH"
assert df.loc[0, "stock_code"] == "301565.SZ"
assert df.loc[1, "stock_code"] == "601609.SH"
print(" [映射] ✅ 债券(11x→.SH / 12x→.SZ)与正股后缀归一化正确")
# 2) 公式:转股价值 / 溢价率 / 双低 重算
assert abs(df.loc[1, "conv_value"] - (11.72 / 10.18 * 100)) < 1e-3
assert abs(df.loc[1, "conv_premium"] - ((114.73 / (11.72 / 10.18 * 100) - 1) * 100)) < 0.01
assert abs(df.loc[0, "conv_value"] - 96.7456) < 1e-3
assert abs(df.loc[0, "double_low"] - (100.0 + dt_round(df.loc[0, "conv_premium"]))) < 1e-6
print(" [公式] ✅ 转股价值/溢价率/双低 重算与源一致(双低=债现价+溢价率)")
# 3) 条款:强赎触发价 = 转股价 × 触发比 / 100 + 状态 merge
assert abs(df.loc[1, "call_ratio"] - 1.30) < 1e-9
assert abs(df.loc[1, "call_trigger_price"] - (10.18 * 1.30)) < 1e-6
assert df.loc[1, "call_status"] == "公告要强赎"
assert df.loc[1, "call_triggered"] == (11.72 >= 13.234) # False
assert df.loc[0, "call_status"] == "" # 中仑转债无强赎状态
print(" [条款] ✅ 强赎触发价=转股价×触发比/100,状态/触发信号正确")
# 4) 报价:债现价==100 占位识别 + 用源溢价率反推修正(胜蓝转02 假 45.37 -> 真 216.33)
assert not bool(df.loc[3, "has_quote"]) # 胜蓝转02 债现价 100 = 占位
assert bool(df.loc[3, "price_fixed"])
assert abs(df.loc[3, "bond_price"] - 217.602) < 0.01 # 反推真实价(新浪 09-29 收盘 217.6)
assert abs(df.loc[3, "double_low"] - 216.33) < 0.05 # 真实双低(不是 45.37)
assert bool(df.loc[0, "has_quote"]) # 中仑转债 债现价100 但真价≈100, 不误判
print(" [报价] ✅ 债现价==100 占位识别 + 源溢价率反推修正(假 45.37 -> 真 216.33)")
# 5) 落库:年分区快照 save/load/时点还原
import tempfile
tmp = tempfile.mkdtemp()
p = save_snapshot(df, "2026-10-03", root=tmp)
assert p.exists() and len(snapshot_on_date("2026-10-03", root=tmp)) == 5
assert snapshot_on_date("2025-01-01", root=tmp) is None
print(" [落库] ✅ 年分区快照 save/load/时点还原正确")
# 6) 三道闸:退债被排雷挡下、无报价债被报价闸挡下, 只剩干净债
rank_raw = df.sort_values("double_low")
assert rank_raw.iloc[0]["bond_name"] == "搜特退债" and bool(rank_raw.iloc[0]["is_risky"])
picked = pick_double_low(df, 5)
assert "搜特退债" not in picked["bond_name"].tolist() # 退债(风险)被挡
assert "胜蓝转02" not in picked["bond_name"].tolist() # 无报价(占位)被挡
assert picked.iloc[0]["bond_name"] == "鸿路转债" # 三道闸后只剩干净债
print(" [排雷] ✅ 退债/无报价被三道闸挡在榜外, 去雷后榜首换人")
print("=" * 72)
print(" 🎉 self-test 全部通过 (6 组断言; 联网取数见 --real)")
print("=" * 72)
return 0
def dt_round(x, n=2):
return round(float(x), n)
# ================================================================== 联网实测
def run_real() -> int:
print("=" * 88)
print(" S3-013 真实数据:AkShare(东财快照 + 集思录强赎) -> 归一化 -> 排雷 -> 落库")
print("=" * 88)
f = CbFetcher()
as_of = pd.Timestamp.now().strftime("%Y-%m-%d")
rt_raw = f.fetch_realtime()
rt = normalize_realtime(rt_raw, as_of)
print(f" 东财 bond_zh_cov : {len(rt_raw)} 行, 已上市 {int(rt['is_listed'].sum())} 只, "
f"有有效报价 {int(rt['has_quote'].sum())} 只, 债现价占位修正 {int(rt['price_fixed'].sum())} 只")
red_raw = f.fetch_redeem()
red = normalize_redeem(red_raw)
print(f" 集思录 bond_cb_redeem_jsl : {len(red)} 行")
print(" 强赎状态分布:", {k: int(v) for k, v in red["call_status"].value_counts().items()})
# 公式对拍:强赎触发价 = 转股价 × 触发比 / 100
cmp = red[red["call_trigger_price"].notna() & red["conv_price"].notna() & red["call_ratio"].notna()]
calc = (cmp["conv_price"] * cmp["call_ratio"]).round(3)
match = (cmp["call_trigger_price"] - calc).abs() <= 0.01
print(f" 触发价公式对拍: {len(cmp)} 行可比, 吻合 {int(match.sum())}/{len(cmp)}")
df = flag_risk(merge_clause(rt, red))
live = df[df["is_listed"] & df["has_quote"] & df["double_low"].notna()]
print(f" 可算双低(已上市且当日有有效报价): {len(live)} 只, "
f"双低 {live['double_low'].min():.2f} ~ {live['double_low'].max():.2f}")
print("-" * 88)
print(" 🔴 双低榜首的陷阱(naive 双低排序 vs 三道闸后):")
print(" naive 双低 TOP5(仅剔除未上市/无报价):")
for _, r in rank_double_low(df, 5).iterrows():
print(f" {r['bond_code']:<11}{str(r['bond_name']):<8} 双低={r['double_low']:>7.2f} "
f"溢价={r['conv_premium']:>7.2f}% 评级={str(r['credit_rating']):<4} 风险={r['is_risky']}")
print(" 过三道闸(已上市 + 有报价 + 排雷)后 TOP5:")
for _, r in pick_double_low(df, 5).iterrows():
print(f" {r['bond_code']:<11}{str(r['bond_name']):<8} 双低={r['double_low']:>7.2f} "
f"溢价={r['conv_premium']:>7.2f}% 评级={str(r['credit_rating']):<4}")
p = save_snapshot(df, as_of)
print("-" * 88)
print(f" 落库 -> {p} ({len(df)} 行 × {len(df.columns)} 列)")
print("=" * 88)
return 0
# ================================================================== 离线演示
def demo() -> None:
rt = normalize_realtime(_syn_realtime(), "2026-10-03")
df = flag_risk(merge_clause(rt, normalize_redeem(_syn_redeem())))
print("=" * 80)
print(" S3-013 演示:双低排序的两类陷阱(占位价 + 退债) · 合成数据")
print("=" * 80)
print(" naive 双低升序(不设闸):")
for _, r in df.sort_values("double_low").iterrows():
tag = "无报价" if not r["has_quote"] else ("风险债" if r["is_risky"] else "可入选")
print(f" {str(r['bond_code']):<11}{str(r['bond_name']):<8} 双低={r['double_low']:>7.2f} {tag}")
print("-" * 80)
print(" 过三道闸(已上市 + 有报价 + 排雷)后:")
for _, r in pick_double_low(df, 5).iterrows():
print(f" {str(r['bond_code']):<11}{str(r['bond_name']):<8} 双低={r['double_low']:>7.2f}")
print("-" * 80)
print(" >>> 胜蓝转02 的 45.37 是『债现价占位 100』造出的假值(真实双低 216.33);")
print(" 搜特退债 / 金田转债 是风险债。三道闸先过再排序, 榜才选不到不能碰的债。")
# ================================================================== main
def main() -> int:
ap = argparse.ArgumentParser(description="S3-013 可转债数据体系(AkShare)")
ap.add_argument("--self-test", action="store_true", help="离线自检(6 组断言)")
ap.add_argument("--real", action="store_true", help="联网:东财快照+集思录强赎->落库")
ap.add_argument("--demo", action="store_true", help="离线:双低选债的三道闸演示")
args = ap.parse_args()
if args.self_test:
return self_test()
if args.real:
return run_real()
if args.demo:
demo()
return 0
ap.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())
免责声明
本文仅为量化数据工程的技术分享,所有代码与真实数据均用于演示,不构成任何投资建议。实盘有风险,下单需谨慎;文中涉及的转债代码、双低区间、强赎状态、评级、触发比例等均为实测当日结果,会随市场 / 数据源 / 披露节奏每日变化,需根据你自己的环境重新校准。投资盈亏自负。