
简介这份《企业通用培训》债券交易员讲义以PDF单文件形式呈现面向银行间市场新入职交易员、金融机构资金与债券业务人员以及备考本币交易员资格的从业者。全篇按十章展开从金融同业市场概述、银行间外汇与本币市场的基本功能与风险防范到市场准入与联网交易流程再到信用拆借、质押式与买断式回购、债券买卖的定价原理、报价询价与成交流程、资金清算与债券结算直至债券分销、货币经纪服务、中国货币网信息系统以及借助F-系统开展市场分析与风险管理。压缩包内仅1个PDF文件约601KB正文附有培训教材目录与概念术语附录便于按章节检索学习。目前已有67人学习。读者可借此理清银行间市场各交易品种的操作路径与风控要点掌握报价撮合、清算结算与应急交易的处理思路为通过交易员培训考核和日常资金头寸管理打下基础。1. 从一份企业通用培训用的《债券交易员讲义.pdf》说起内部知识库项目最常见的起点是业务方直接甩过来一个几十页的《债券交易员讲义.pdf》附一句让新人能问它。这份讲义通常是企业通用培训材料中英文混排、净价/全价并列、久期和凸性带公式、后半段全是现金流表和报价示例。真正的难点不在 OCR而在于把修正久期这个词和它旁边那个 4.37 正确绑到一起——版面一乱数值就会挂到别的术语头上。适合读这篇的人要把中文金融 PDF 变成可检索、可计算数据的后端与算法工程师以及需要评估解析质量的数据同学。2. 用 PyMuPDF 抽取《债券交易员讲义.pdf》的文本层与表格坐标2.1 先判定讲义是文本型 PDF 还是扫描件动手写代码之前先花两分钟做体检路线选错了后面全是无用功。Poppler 工具链的三条命令足够pdfinfo 债券交易员讲义.pdf | head -20 # 页数、Producer、是否加密 pdffonts 债券交易员讲义.pdf | head -20 # 有字体列表说明存在文本层 pdftotext -f 1 -l 3 债券交易员讲义.pdf - | head -40 # 抽查前三页文本pdffonts输出为空、或者pdftotext抽出来是满屏 CID 码和方块说明这是纯图片页得走版面分析加 OCR 的路线。而只要字体列表里能看到嵌入子集字体pdftotext也能抽出成句的中文就可以直接用坐标抽取精度和速度都远好于 OCR。现象判定处理路线pdffonts 有嵌入字体pdftotext 出正常中文文本型PyMuPDF 直抽 pdfplumber 抽表pdffonts 输出为空扫描件版面检测 OCR 表格结构还原表格边框是矢量线有框表pdfplumber 的 lines 策略表格只靠单元格底色分隔无框表text 策略 列坐标聚类2.2 不要直接用 get_text()先拿 block 和 bboxpage.get_text()会按内部阅读顺序把整页拼成一个字符串遇到双栏排版或者表格列会被串成一行净价 98.765 全价 100.011 变成 净价 全价 98.765 100.011。稳妥做法是走dict模式拿到块、行、span 三层结构每一层都带 bbox。import fitz # PyMuPDF doc fitz.open(债券交易员讲义.pdf) page doc[12] data page.get_text(dict) for block in data[blocks]: if block[type] ! 0: continue # type1 是图片块讲义里的截图示例都在这一类 for line in block[lines]: for span in line[spans]: x0, y0, x1, y1 span[bbox] print(round(span[size], 1), span[font], (round(x0), round(y0)), span[text][:30])拿到 bbox 之后判断这页是不是双栏只要看行左边界 x0 的分布有两个明显峰值的按x0 页宽/2分成左右栏分别排序再拼成阅读顺序。为 NULL 的 bbox 不用管PyMuPDF 对文本型 PDF 一定给值。抽表格之前先按y0排序能让后续术语-数值匹配的窗口更准。2.3 用字号分布切出讲义的标题层级讲义章节结构不用猜字号就是最可靠的信号。先统计全文字号分布from collections import Counter sizes Counter() for p in doc: for b in p.get_text(dict)[blocks]: if b[type]: continue for l in b[lines]: for s in l[spans]: sizes[round(s[size], 1)] len(s[text].strip()) print(sizes.most_common(10)) # 典型输出[(10.5, 41230), (12.0, 6200), (15.0, 900), (18.0, 260)]出现次数最多的那个字号就是正文往上数第二档大概率是节标题最高档是章标题。企业培训讲义的字号梯度一般就是 18/15/12/10.5 这四档。2.3.1 中文字体加粗判断的坑不能只看span[font]里有没有 Bold。很多中文字体根本没有真正的粗体字形排版软件用的是描边或者替换字体来模拟加粗字体名里不带 Bold视觉上却是粗的。更稳的判定是三个条件投票字号大于正文 2pt 以上、整行只有这一句话没有标点结尾、上下各有一段空白。三条中命中两条就判为标题误判率能压到很低。2.4 表格抽取pdfplumber 的参数怎么调债券讲义里最关键的两类表是现金流表和久期凸性对照表抽错一列后面算出来的 YTM 全错。pdfplumber 的extract_table默认参数在扫描件和 Word 转出的 PDF 上表现不同需要按表格线的实际形态调。import pdfplumber with pdfplumber.open(债券交易员讲义.pdf) as pdf: page pdf.pages[20] # 有矢量边框的表走 lines 策略 table page.extract_table({ vertical_strategy: lines, horizontal_strategy: lines, snap_tolerance: 3, join_tolerance: 3, intersection_tolerance: 5, edge_min_length: 10, }) for row in table[:5]: print(row)参数含义和调整方向如下调参时一次只动一个否则无法归因。参数默认值讲义场景下的调法vertical_strategylines有框表用 lines纯靠空格对齐的现金流表改 texthorizontal_strategylines同上两张策略要保持一致snap_tolerance3表格线轻微错位、行被拆成两行时调到 4~6join_tolerance3虚线或点线边框容易断开调到 6intersection_tolerance5相邻单元格文字粘连时调到 8 再试edge_min_length3页眉短横线被误判成边框时调到 15调完还是抽不出来用page.to_image(resolution200).debug_tablefinder()导出带检测框的图肉眼确认是横线没识别到还是竖线没识别到比反复改参数快得多。此外记得开extract_table({text_...})之外的兜底对无框表改用extract_words()拿到每个词的 x0按 x0 聚类成列再按 y0 分行。3. 债券术语与数值绑定把久期、基点、净价全价抽成结构化字段3.1 同一个数字在讲义里有多种含义讲义里数字的语义密度极高。同样是 4.37可能是修正久期也可能是全价的一个分位同样是 3.25可能是票面利率也可能是 3.25 个基点。脱离术语谈数字毫无意义所以抽取的最小单位不是数字而是术语-数值-单位三元组。这一步做扎实了后面无论做检索还是做计算都是顺的。3.2 用正则把术语和数值绑成三元组术语表要按长度倒序匹配否则修正久期会被久期抢先截断。import re TERMS { 修正久期: modified_duration, 麦考利久期: macaulay_duration, 久期: duration, 凸性: convexity, 到期收益率: ytm, 应计利息: accrued_interest, 净价: clean_price, 全价: dirty_price, 票面利率: coupon_rate, } UNIT r(年|%|bp|元|个基点) PAT re.compile( r(?Pterm |.join(sorted(TERMS, keylen, reverseTrue)) r) r[:\s]* r(?Pvalue\d(?:\.\d)?) r\s* UNIT r? ) def extract(text): out [] for m in PAT.finditer(text): out.append({ field: TERMS[m.group(term)], raw_term: m.group(term), value: float(m.group(value)), unit: m.group(unit), span: m.span(), # 保留偏移用于回原文定位和人工复核 }) return outsorted(TERMS, keylen, reverseTrue)是这段代码的关键它保证长术语优先。span字段别省抽错了能一眼看出是哪一段的哪几个字符。单位匹配用?表示可选因为讲义里到期收益率 3.4经常省略百分号这种情况要靠同页是否存在%号来补默认单位。3.3 归一化把 bp、百分比、元统一到可计算单位抽出来的原始值必须归一否则 3.25% 和 45bp 混在一起没法比较。字段讲义原值归一化后注意coupon_rate3.25%0.0325百分比一律转小数ytm3.40.034缺 % 时按上下文补别默认成 3.4duration4.37 年4.37统一以年为单位convexity21.621.6无量纲不做换算accrued_interest1.23 元/百元1.23面值基准固定为 100spread45bp0.0045bp 除以 100003.4 讲义公式还原成可计算代码PDF 里抽出来的公式往往是乱序字符D Σ t·PV(CFt) / P 可能变成 D t PV CFt P Σ /。可行做法是先用 bbox 把公式区域圈出来含希腊字母、上下标字符密集、x 间距异常标成 formula 块单独处理正文里只留公式编号和一句自然语言描述。公式本身用代码实现一遍作为校验基准。def macaulay_duration(cashflows, ytm, freq2): cashflows: [(期数, 现金流)]讲义第 2 章的公式直接映射到这段 pv [(t, cf / (1 ytm / freq) ** t) for t, cf in cashflows] price sum(v for _, v in pv) return sum(t / freq * v for t, v in pv) / price def modified_duration(mac_d, ytm, freq2): 麦考利久期转修正久期freq 为每年付息次数 return mac_d / (1 ytm / freq)freq2对应半年付息这是国内债券讲义里最常见的默认值ytm传入的是小数形式所以 3.4% 要传 0.034 而不是 3.4。这两个参数写错久期能差出 1% 以上做校验时会一直报警。4. 把《债券交易员讲义.pdf》切片进知识库分块、向量化与混合检索4.1 按标题层级分块别按 500 字硬切固定字数切分会把久期的定义和它的公式切成两块检索到前半句答不出问题。用第 2 章抽出来的标题树做递归切分每个块带上所属章节路径。def build_chunks(blocks, max_chars900, overlap120): blocks: 阅读顺序排好的 [{level: 1|2|3|None, text: str, page: int}] chunks, h1, h2, buf, start_page [], , , [], None def flush(): nonlocal buf, start_page if not buf: return text \n.join(buf) if len(text) max_chars: chunks.append({h1: h1, h2: h2, text: text, page: start_page}) else: cur for p in text.split(\n): if cur and len(cur) len(p) max_chars: chunks.append({h1: h1, h2: h2, text: cur, page: start_page}) cur cur[-overlap:] \n p # 保留尾部重叠避免切断定义 else: cur (cur \n p) if cur else p if cur: chunks.append({h1: h1, h2: h2, text: cur, page: start_page}) buf, start_page [], None for b in blocks: if b[level] 1: flush(); h1, h2 b[text], elif b[level] 2: flush(); h2 b[text] else: if start_page is None: start_page b[page] buf.append(b[text]) flush() return chunks入向量库时不要只送text把标题路径拼在前面f{h1} {h2}\n{text}。这样久期与凸性这个节下的任何块都自带领域上下文短查询的召回明显变好。参数建议值理由max_chars800~1000讲义单节正文通常 300~800 字一块对应一节最贴overlap100~150公式定义和结算规则常跨段标题前缀h1 h2提升基点净价这类短词召回表格块单独成块每行补表头表转文本后失去列名检索时对不上4.2 混合检索BM25 救短词向量救同义金融术语里有一批极短但极关键的词比如bp净价T1向量模型经常把它们糊成一团BM25 的精确匹配反而稳。两路各取前 20再用 RRF 融合。import jieba from rank_bm25 import BM25Okapi # 自定义词典必须加否则修正久期会被切成修正/久期 for w in [修正久期, 麦考利久期, 中债估值, 净价, 全价, 基点, 到期收益率]: jieba.add_word(w) corpus [list(jieba.cut(c[text])) for c in chunks] bm25 BM25Okapi(corpus) def bm25_search(q, k20): scores bm25.get_scores(list(jieba.cut(q))) idx sorted(range(len(scores)), keylambda i: -scores[i])[:k] return [i for i in idx if scores[i] 0] def rrf(rank_lists, k60): rank_lists: 每路检索返回的 doc_id 有序列表 score {} for rl in rank_lists: for rank, doc_id in enumerate(rl): score[doc_id] score.get(doc_id, 0) 1 / (k rank 1) return sorted(score.items(), keylambda x: -x[1])k60是 RRF 里常用的平滑常数取值越大靠后名次的权重衰减越慢长尾文档更容易被捞回来规程类问题建议保留 40~80 之间。BM25 前的分词质量决定上限讲义里净价和全价只差一个字词典不加两者检索结果会互相污染。4.3 用讲义自带的课后题造评测集企业培训讲义通常每章末尾配了思考题这是现成的高质量评测集比人工编 query 靠谱。eval_set [ {q: 修正久期和麦考利久期的换算关系, gold_h2: 久期与凸性}, {q: 净价怎么换算成全价, gold_h2: 债券报价与结算}, {q: 半年付息的债券怎么算到期收益率, gold_h2: 收益率计算}, ] def recall_at_k(ranks, eval_set, k5): hit 0 for item, rl in zip(eval_set, ranks): top [chunks[i][h2] for i in rl[:k]] hit int(item[gold_h2] in top) return hit / len(eval_set)指标计算方式合格线Recall5前 5 条命中目标节的比例≥ 0.85MRR第一条正确答案名次倒数的均值≥ 0.7页码命中率返回块页码与讲义原页一致≥ 0.95用于定位引用页码命中率低通常不是检索的问题而是第 2 章分块时start_page记漏了先回去查分块层。5. 用讲义里的现金流表反推 YTM校验解析结果解析对不对肉眼翻页看是查不完的用讲义自己的数字做闭环校验最快。抽出现金流表和旁边印着的全价反解 YTM再跟讲义正文写的到期收益率比误差落到 1bp 以内说明这一页抽对了。def solve_ytm(cashflows, price, freq2, lo-0.5, hi1.0, tol1e-10): cashflows: [(期数, 现金流)]price 为全价按每百元面值 def pv(y): return sum(cf / (1 y / freq) ** t for t, cf in cashflows) for _ in range(200): mid (lo hi) / 2 if pv(mid) price: # 价格随收益率单调递减据此缩区间 lo mid else: hi mid if hi - lo tol: break return (lo hi) / 2二分法的搜索区间取[-0.5, 1.0]足够覆盖负利率到极端高息的所有情形迭代 200 次远超实际收敛所需配合tol提前退出即可。注意price必须是全价传净价进去算出来的收益率会系统性偏高恰好等于应计利息带来的偏差。症状大概率原因定位手段算出的 YTM 偏高几十个 bp把净价当全价用了检查该页是否漏抽应计利息现金流期数总是奇数付息频率取成 1翻讲义页脚的付息频率说明第 1 期现金流异常大含首期应计利息补偿比对起息日与结算日某行少一列数据表格抽列丢失回到 2.4 用 debug_tablefinder久期校验差 1% 左右修正久期和麦考利久期混用看公式分母是否除了 (1y/f)把 1bp 写成断言塞进 CI每次讲义改版重新跑一遍解析流水线哪一页的结构变了立刻报出来比人工抽查省事得多。assert abs(solve_ytm(cfs, dirty_price) - ytm_from_pdf) 1e-4, ytm 偏差超过 1bp检查该页表格抽取本文还有配套的精品资源点击获取