每天一份 A 股「规则即收益」机会速览:谁今天要申购/缴款、哪只转债今天上市、 哪些配债临近登记日、哪些 LOF/QDII 出现折溢价、市场上有哪些要约/吸并线索、 哪些转债批文下来了还没发。
它不做统计检验、不预测价格、不给估值 —— 这一层的收益来自规则本身,不需要显著性。 工具要做的只有一件事:别让你漏掉时点。
⚠️ 免责声明:本项目是信息聚合与提醒工具,不构成投资建议,作者不提供证券投资 咨询服务、不提供荐股服务。数据来自第三方公开接口,不保证准确、完整、及时。报告中的 「预估收益」是基于假设的算术推演,不是可实现收益。请务必阅读完整的 DISCLAIMER.md。
git clone https://github.com/sailinsky76/cb-scanner.git
cd cb-scanner
pip install -r requirements.txt
python run.py --mock # 离线自检,不联网,先确认环境是通的
python run.py # 联网跑一次,报告落在 reports/改行为改 config.yaml(不用动代码,每一项都有注释写清楚了为什么是这个默认值)。
想让报告推到手机,见 SETUP_AUTORUN.md:
定时开机 → 跑批 → 手机收通知 → 自动关机的完整链路。
完整输出样例见 docs/sample_report.md。
| 文件 | 讲什么 |
|---|---|
README.md(本文件) |
每一处为什么这么设计 |
DISCLAIMER.md |
免责声明与数据口径边界 |
SETUP_AUTORUN.md |
无人值守跑批 + 手机推送装配清单 |
docs/dev/STATE.md |
开发用:当前进度一页纸 |
docs/dev/HANDOFF.md |
开发用:干活纪律 + 代码地图 |
docs/probes/ |
每一句措辞的出处(接口原始返回) |
docs/dev/下的三份是开发过程文档,写给接着改这个项目的人。只想用工具的话不用看。
| 模块 | 接口(akshare) | 干什么 |
|---|---|---|
可转债打新 cb_ipo |
bond_zh_cov() |
申购日到点提醒(信用申购,T 日不占款);中签缴款提醒(连续 3 次不缴款拉黑半年,单列硬提示);上市日提醒(v5.0,打新流程里唯一要你做判断的一天,只报时点不报价格) |
配债一手党 cb_allotment |
bond_zh_cov() + stock_zh_a_hist() |
登记日临近的配债名单;按沪 1 手 / 深 1 张分别算"需持股 / 占用市值"(v5.0,两市规则不同);印含权量(%) 与"正股每跌 1% 需上市溢价 X% 才打平";按正股发债前 3 月涨幅 < 30% 筛,超阈值标红(抢权回调风险) |
LOF/QDII 折溢价 fund_premium |
ETF 侧 fund_etf_spot_em() + fund_etf_fund_daily_em();LOF 侧 fund_etf_category_sina() × fund_etf_category_ths();可选 qdii_*_index_jsl() |
扫全部场内基金折溢价,超阈值提示"规避/申购套利"或"折价买入";折价侧按可执行度三档排序,档内按**绝对收益(元)**排 —— 买入价取卖一、仓位受日成交额约束,直接给「可投(万)/预估(元)」与组合合计 |
事件套利 event_arb |
stock_zh_a_disclosure_report_cninfo(keyword=…) |
巨潮全市场关键词滚动检索:要约收购/现金选择权/换股吸并。只做线索发现,条款需人工核对 |
转债退出提醒 cb_redeem |
bond_zh_cov() |
最后交易日临近提醒(v5.1)。只说「最后交易日」,不给任何一只债贴「强赎」或「到期」标签 —— 数据源把两者放在同一列,样本不足以分开;到期日一并印出来,两个原始日期你自己看 |
转债获批公告 cb_approved |
stock_zh_a_disclosure_report_cninfo(keyword=…) |
批文已下、发行公告还没出的那一段(v5.9)。按正股代码回配转债总表判断发没发;获批满 90 天仍查不到发行记录的只标事实不给原因 |
统一输出为 Opportunity,按紧急度(🔴今日 > 🟠临近 > ⚪观察)和日期排序,顶部单独聚合"今日待办"。
目录层级不能压平——各模块之间是包内相对导入(from ..models import),
文件全丢到同一层会直接 ImportError。解压后保持下面的结构:
cb_scanner/
├── run.py # 主入口:编排六个源 → 渲染 → 落盘
├── verify_report.py # 报告级自检:解析渲染后文本,验证五条不变量(--mock 时自动运行)
├── check.sh # 一键离线 DoD(七项,断网下约 3 分钟)
├── README.md # 本文件:每一处为什么这么设计
├── LICENSE / DISCLAIMER.md # MIT 许可 / 免责声明与数据口径边界
├── SETUP_AUTORUN.md # 无人值守跑批 + 手机推送装配清单
├── notify.py # 把报告推到手机(独立文件,不碰 run.py / scanner/)
├── config.yaml # 用户配置(改这里,不用动代码)
├── requirements.txt
├── selftest_fixes.py # 取数健壮性 + 措辞回归(不联网)
├── tests_utils.py # 工具函数单测
├── tests_sources.py # 源层单测
├── replay_20260808.py # 08-08 实盘复现
├── replay_20260809.py # 08-09 实盘复现
├── diag_sources.py # 联网:行情接口在你的网络下通不通
├── diag_allotment.py # 配债专用体检:判定「真没标的」还是「列位移了」(联网)
├── diag_lof_coverage.py # LOF 三条路的覆盖率实测(联网)
├── diag_redeem.py # 阶段 2 探针一:最后交易日拿不拿得到(联网,不接进 scanner/)
├── diag_redeem2.py # 阶段 2 探针二:这一列能不能分开强赎与到期(同上)
├── diag_cbplan.py # 阶段 3 探针:触发点能不能前移到获批那一段(同上)
├── docs/
│ ├── sample_report.md # --mock 跑出来的输出样例
│ ├── dev/STATE.md # 开发用:一页当前状态,接手先读这个
│ ├── dev/KICKOFF.md # 开发用:新对话开头要粘的引导语
│ ├── dev/HANDOFF.md # 开发用:现在在哪、下一步做什么、哪些线不能踩
│ └── probes/probe*.txt # 探针原始输出:每个列名和每句措辞的出处
├── reports/ # 报告输出目录(.gitignore,运行时自动创建)
└── scanner/
├── __init__.py
├── config.py # 内置默认值 + config.yaml 深合并
├── models.py # Opportunity / SourceResult / Kind / Urgency
├── utils.py # safe_call 兜底、disk_cache 当日缓存、日期数值解析、HTML/链接清洗
├── report.py # console / markdown / html 三种渲染
└── sources/
├── __init__.py
├── base.py # Source 抽象基类 + Context 运行上下文
├── cb_ipo.py
├── cb_redeem.py # v5.1:转债退出提醒(最后交易日)
├── cb_approved.py # v5.9:转债获批公告(批文已下、发行公告还没出)
├── cb_allotment.py
├── fund_premium.py
└── event_arb.py
跑一次 python run.py --mock,能出报告且末尾显示 报告级自检通过 就说明结构对了。
再跑 python selftest_fixes.py 验证取数健壮性逻辑(不联网,含 75 条断言),
python diag_sources.py 验证行情接口在你的网络下是否可用(联网)。
配债栏长期 0 条时跑 python diag_allotment.py(联网),它会给出结论而不是让你再猜一轮:
退出码 0 = 接口正常(结论里说明是"窗口内有标的"还是"真空窗"),
退出码 2 = 代码/接口问题(取数失败 / 列名改版 / 登记日整列为空 / 列位移,四种分别报)。
报告级自检也可单独跑:python verify_report.py reports/scan_20260808.txt。
想推进阶段 2(强赎退出线)之前先跑 python diag_redeem.py(联网),
退出码 0/1/2 分别对应"日期拿得到、可以写实现"/"名单有日期没有"/"取数问题"。
判据只有一个:换一天跑,这句话会不会变。
| 层 | 放什么 | 印在哪 |
|---|---|---|
flags |
只有这一条才成立的数值事实(这只票的滑点是 0.50pt) | 紧跟在条目下面 |
notes |
本次运行才成立的结论(今天折价侧 6 条、合计 910 元) | 栏目抬头 |
footnotes |
每天一字不变的口径与假设(赎回费为什么按 1.5% 算) | 报告末尾,全报告去重印一次;HTML 里折叠 |
为什么要有这一层。 v4.1→v4.3 每一轮都往提示里加话,没有一轮往外减 ——
到 v4.3 最差一条挂着 5 句 326 字,其中两句是可证的重复(扣掉滑点…-1.13% 和
动作行同一个数字;仓位卡在 0.6 万 和 约 0.6 万封顶 同一个上限)。读到第三句人就跳过了,
后面两句等于白写。
v4.4 的处理不是"写短点"——那是句没有约束力的话,下一轮照样涨回来。而是:
- 删掉可证的重复(结论没丢,改由动作行/metrics 单独承载);
- 常量解释下沉
footnotes,正文里只留数字; verify_report.py加第⑤条不变量:提示预算。单条 ≤3 句、≤150 字,单句 ≤60 字, 栏目说明 ≤160 字,超了--mock直接退出码 2。
第 3 条才是要害:想加第 4 句,就得先删一句。 预算不是审美偏好, 是"注意力有限"这件事在代码里的表示。单句 60 字的上限顺带堵住了 "把三句用顿号缝成一句"的绕法。
实测(08-08 实盘那 9 条):
| v4.3 | v4.4 | |
|---|---|---|
| 正文(决策区) | 5,726 字 | 4,253 字(−26%) |
| 最差单条 | 5 句 / 326 字 | 3 句 / 126 字(−61%) |
| 161131 那条 | 五个提示挤成一堵墙 | 三句,每句都是数 |
这一轮是拿 08-09 的实盘输出反推出来的。改的全是措辞、分档和提示预算,
数值一个没动(replay_20260809.py 逐条对过,差异都在输入精度内)。
一、「没取到」不等于「开放」
申赎状态只有 LOF 那一路(同花顺)带,ETF 两路的接口根本不返回。上一版把 「取不到」和「确认开放」合并成同一档,于是 08-09 那份报告写着 「溢价侧 37 条…27 条可兑现」——那 27 条全是跨境 ETF,申购状态一条都没取到。 而溢价 24.69% 的纳指科技 ETF 恰恰大概率是限额或暂停(不限额撑不到 24.69%), 等于在全页最显眼的一行上断言了反面。
现在四档:可兑现 → 状态未取到 → 成本吃光 → 闸门暂停。这类条目动作词改成
「赎回状态未取到,能不能兑现待核」,并且不给它算净收益和预估——印一个数
等于替读者认定这条路走得通。
排序上给了个开关 demote_unknown_gate(默认 true):跨境 ETF 全在这一档,
一降档那张高溢价规避清单会被境内 LOF 顶掉两三只。计数和措辞不受开关影响
——那是对错,不是偏好。
二、场内规模 那一列不许静默消失
_share_err 上一版只写不读。深交所份额接口一挂,这一列就整片不见、一条退市线
提示都不出——和「沪市取不到」、和「都在线上」长得一模一样。现在它进栏目级提示:
「退市线本次未判定…但这不代表它们都在线上」。
同时口径脚注改成跟着这一列走,而不是跟着「触线」走。上一版挂在触线分支里, 所以 08-09 那天 5 只有这一列、5 只没有,而「只覆盖深市、没这列 ≠ 安全」一个字没印。
三、提示预算这次是真的会被撞破
实盘 165516 已经 3 句 140 字,160215 是「滑点 + 流动性 + 退市线」——只差一个
「最新价落在盘口外」就是 4 句。而 run.py 的自检门是 if args.mock,
那五条不变量从来没在实盘输出上跑过一次。两处都改了:
- 自检每次运行都跑;实盘不通过时报告照出照写盘,退出码降级到 1
- 腾预算:「盘口之外」压成滑点句的后缀「(按收盘盘口推)」;「跨境」从整句压成
形态: ETF·跨境标记 + 脚注;「数据陈旧 / 净值漂移」从每条一句改成栏目级说一次 (它们对同一帧的每条都是同一个数据日期、同一个 lag,本来就是 notes 层的东西)
四、组合合计要能被验算
_portfolio_note 上一版按全池贪心,max_discount 一截断,印出来的合计就再也
无法由看得见的数复算——探针实测 12 条折价、max_discount=3 时印 4,680 元,
可见条目只算得出 1,562 元,不变量②直接判负。现在只铺可见条目,本金没铺完时明说
「后面的条目被 max_discount=N 截断」。
顺带:event_arb 先按公告时间倒序再截断(原来按关键词分组顺序截,却写
「最近的 N 条」),判据从 >= 改成 >(恰好 20 条时不再谎报截断);
_sanitize 补上 fund_keywords 归一(漏了的话 list("终止上市") 会拆成四个单字
关键词去打巨潮);场内规模(万) 不再印成 2486.0。
零新数据源——三件事的数据全在 bond_zh_cov 那张表里,一个接口都没加。
起因是复盘一篇讲配债套利的文章:它的策略不能进这个工具(方向性持仓,
要扛正股 beta 和信用风险,破坏"低容量套利"这份报告的契约),但它的
度量(含权量)和时点(核准/公告/登记/缴款/上市/强赎那一串)
正好戳在这个工具的两个空白上。
一、上市日提醒(新增)
打新这条线上唯一需要你自己做判断的那一天就是上市日(卖还是留、什么价卖), 而 v4 的工具把你推进仓位之后就再也不提这只债了——申购、缴款两句说完就断片。
窗口按交易日推,理由和缴款那条一样:写日历天的话周五跑只盖到周日,
下周一上市的债会漏掉。申购侧那两道门(min_rating / min_convert_value)
不套在这一条上——那是"要不要申购"的判据,债已经在手里了,不该因为
评级低就不提醒你它今天上市。
同时刻意划一条界:只报时点,不报价格。市面上那种"合理价值 137"是模型输出、 没有出处,一旦印进来,这份报告"每个数都能指回一个规则或一张表"的可信度就没了。 想要参照,去查近期新债首日涨幅的实际分布(那是可查的事实)。 用例里钉了一份定价词汇黑名单,往这一条上加任何估值数都会当场变红。
二、最小配售单位分沪深(口径错,唯一一处会给错数的)
沪市原股东优先配售的最小单位是 1 手(10 张 / 1,000 元面值), 深市是 1 张(100 元面值)——前者走上交所发行公告的精确算法取整, 后者走中国结算深圳分公司的配股业务指引。"一手党"这个江湖名字本身就是沪市专属, 深市根本没有"手"这个配售单位。
v4.6.1 及以前两市统一按 1,000 元算,深市转债的"需持股 / 占用市值"全被放大了 10 倍。同一只标的(每股配售额 0.8、正股价 9.50):
| v4.6.1 | v5.0 | |
|---|---|---|
| 深市 12x | 1,250 股 / 11,875 元 | 125 股 / 1,188 元 |
| 沪市 11x | 1,250 股 / 11,875 元 | 1,250 股 / 11,875 元(不变) |
好消息是这一栏常年 0 条,所以还没害到人。列名跟着单位走(深市印
配1张需持股),不跟的话那一行的列名会和它下面的数对不上。
认不出代码前缀时按沪市高估,但会明说一句——不能静悄悄给一个买不到的数。
还有一半没做进去,写在脚注里:两市不足 1 个单位的零头都会进位, 但进位是竞争性的(沪市按尾数从大到小、深市按数量大小排序,直到总量配完)。 所以"保证配满"和"博进位"是两个不同的数,你的尾数能不能进上去取决于当天有多少 账户排在你前面,事前不可知。工具只印前者——后者是一个赌注,不是一个可计算的量。
三、含权量:这是警告标签,不是功能增强
含权量(%) = 每股配售额 ÷ 正股价,即每 100 元正股市值能配到多少元转债。
配套印一句 100 ÷ 含权量(%):正股每跌 1%,需上市溢价 X% 才打平。
推导:正股跌 1% 亏掉「占用市值 × 1%」,而占用市值 = 面值 ÷ 含权量, 所以要靠转债这一侧赚回 1/含权量 的溢价——纯算术恒等式,不含任何定价假设, 与面值无关(沪深通用)。含权量 12.5% 印出来是"需 8% 才打平", 8.42% 是"需 12%",拿它对一眼近期新债首日涨幅的实际分布,一眼就知道该不该动手。
之所以说它是警告标签:这一栏做的正是那篇文章自己说会亏钱的那件事。
它靠"股权登记日"出条,而登记日只在发行公告之后才有值(通常只提前 2-3 个
交易日)——那时正股多半已经高开完了。真正的埋伏窗口在证监会核准到发行公告之间的
1-2 个月,那段数据不在 bond_zh_cov 里(那是已发行转债的表),本工具现在看不到。
这两件事都写进了脚注,不是藏在代码注释里。
四、上市时间那一列的取证(路线图里没有,我加的)
上市时间 和配债那个"登记日"是同一类静默陷阱,而且更隐蔽:akshare 按位置
命名 bond_zh_cov 那 70 多列,东财加一列就整体错位——错位之后申购/缴款照常出条
(各自错到别的日期字段上,看着仍是日期),只有上市提醒天天 0 条,
和"本周没有新债上市"长得一模一样。
语义不变量是现成的:上市日必在申购日之后。这条大面积不成立就报"列位移"; 整列取不到值单报一句;正常空窗只在本栏一条都没有时才说话——报告不该为了 自证而变长。4 行的自检小表不误报(那里面只有 1 行有上市日)。
selftest_fixes.py 60 → 64 条:上市提醒(交易日窗口 + 门槛不串 + 定价词汇黑名单)、
沪深单位、含权量恒等式、列位移取证各一条。三套测试 + 两份回放 + --mock
三格式全流程都过,两份回放的数值与名次一字未动。
没动的两件:中签率 的 ×100 口径仍是悬案(要联网对一次公告才能坐实);
Kind.CB_ALLOT 那个"一手党"名字对深市现在不准确了,但改它会牵动回放和 README,
不该顺手做。
复查 v4.6 的代码与 08-09 实盘输出找出来的。同样一个数值都没动 (两份回放逐条对过),改的全是该说没说 / 说了两遍 / 说给谁看。
一、盘口异常那句挂错了分支(唯一一处逻辑错)
fund_premium.py 里「最新价落在买一/卖一之外」的 elif 接在了
「仓位卡在 X 万」那个 if 上,而注释写的是「滑点那句没触发时仍然要说」。
判据本来只有一个:滑点那句说没说过。接错的后果是两头都不对:
- 最新价高于卖一的倒挂快照 + 仓位又被成交额卡住 → 走了那个
if, 盘口异常一个字不印。读者只看到「买卖价差…已经折进净收益」, 而这个盘口本身就是坏的。08-09 那份没露相纯属巧合:触发它的三只 (165516 / 501098 / 160215)恰好都同时被成交额卡住。 - 滑点那句已经带了「(按收盘盘口推)」后缀、成交额又充裕 →
elif反而成立, 同一件事印两句 96 字。
改完还顺手把价差那句的让位判据从「滑点句印过没」扩成「盘口那两个价印过没」—— 倒挂那条把买一和卖一都印了,比价差句还全,同样该让位;不扩的话 「倒挂 + 冷门 + 深市小规模」会凑出 4 句撞破 ⑤。
二、** 记号原样打进了终端和网页
提示与脚注是照 markdown 写的(**这样** 标重点),可 config.yaml 默认的
formats 是 ["console", "html"],markdown 那一路根本没开——08-09 那份 HTML 里
7 处、console 里 4 处裸露的 **…**。不在源字符串里删(删了 markdown 就白写),
改在渲染层分流:console 去记号、html 转 <b>、markdown 原样。
console 那一路收在 render_console 的出口做,逐字段去总会漏一个。
三、事件套利是「最旧的排最前」
v4.6 刚把截断改成「按公告时间倒序留最新的 N 条」,渲染层却还在按
action_date 升序排——08-09 那份里最新的 159717(基金合同终止,08-08)
被摆在最下面,最旧的 08-05 打头。留下的是对的,摆出来的次序是反的。
Opportunity 加了 date_desc:这一栏的日期是已发生的公告时间,越新越先看;
打新/配债那种「将到的时点」仍然升序,越早越急。
四、溢价动作词把已知条件退回成假设
申购: 开放 已经写在 metrics 里、分档也是按它数进「可兑现」的,动作词却还是
「规避 / 若申赎开放可申购套利」。现在照实说:「规避(申购开放,可申购套利)」,
限制大额的写「申购限制大额」。这和折价侧被改掉的「(需可赎回兑现)」是同一个
毛病,v4.6 漏了溢价这一侧。
selftest_fixes.py 55 → 60 条,四处各钉一条,外加倒挂盘口的最坏组合
(盘口 / 流动性 / 退市线三句齐全且不撞破预算)。
日报里「已公告赎回转债」「强赎倒计时」对应的是硬时点,而这一类的价值全在 两个日期上:最后交易日和最后转股日。过了最后交易日还没动手,拿到的 就是赎回价(通常 100 元出头)——一只 130 元的债当场变成 100 元。这是这个工具 目前最大的空白:它管你进场(申购/配债/上市),不管你离场。
但实现还没写,先写了探针。 麻烦在于那两个日期在公告正文里,不在标题里,
而 event_arb 现在做的是标题关键词检索——这将是它第一次要读正文。
读不读得到,断网猜不出来。按项目自己的规矩:探明之前不写实现(v3 那次栽的
就是照着"应该有"去写)。所以 diag_redeem.py 不产出任何机会条目,也不接进
scanner/,它只回答三个问题:
- 这台机器上的 akshare 有哪些和赎回相关的接口?各返回什么列?
(curated 候选表 + 自动扫
dir(akshare)——我猜的那张表会过时,机器扫的不会) - 「最后交易日 / 最后转股日」能不能只靠表字段拿到?拿不到的话, 巨潮那个检索接口返不返回正文?
- 一份已知的强赎名单能对上几只?各自的最后交易日是什么?
退出码就是结论:0 = 日期拿得到,可以写实现了;1 = 名单拿得到、日期 拿不到,得读正文或换数据源;2 = 连名单都拿不到,那是取数问题。
python diag_redeem.py # 完整探测(约 30-60 秒)
python diag_redeem.py --fetch 2 # 额外抓 2 份公告正文试解析(需 requests)
python diag_redeem.py --names 春23,嘉泽 # 换一份对照名单
一个方向相反的陷阱,顺便一起探了。「不提前赎回」和「提前赎回」共享
赎回 这个词,含义却完全相反:前者是"这次不赎、继续持有",后者是"限期离场"。
按关键词粗筛会把两者混成一栏,而混错的代价不是少赚,是照反方向操作。
探针会分别数出来,classify_title() 里「不提前赎回」判在「提前赎回」之前,
就是为了这个——后者是前者的子串,顺序反了方向就反了。
解析器里唯一会静悄悄出错的那条路,标出来了。 公告里有这种并列写法:
最后交易日、最后转股日分别为2026年8月18日、2026年8月20日
从"最后转股日"往后找,先撞上的是 8月18日——那是前一个标签的值,
而且不会报错,就这么印一个错日期出去。所以解析结果带一个 ambiguous 标记:
标签和日期之间夹了另一个标签、中间出现「分别/依次」、或紧挨着的前文就是
另一个标签,三条命中任一条就标存疑。不猜哪个对,只把疑点标出来——
探针的职责是报告,不是替你赌一个。宁可多标:假警报只多印一个 ⚠,
漏标就是一个静悄悄的错日期。
classify_title() 和 extract_key_dates() 是纯函数,selftest_fixes.py 里
离线钉了 2 条(v5.0 当时是 66 条里的最后两条;现在总数已到 75 条)——探针要联网才跑得动,但能离线验的部分
必须验,否则等到联网那天才发现解析器自己是坏的,等于白探一次。
验收标准现成:能不能把 08-09 那份日报里的 9 只(春23、嘉泽、宝莱、文科、 正帆、齐翔2、万孚、青农、应流)都跑出来,并给出各自的最后交易日。 名单写死在脚本里,但它会随时间失效(赎回完就从表里掉了),所以脚本同时会 把当天的完整强赎名单打出来,晚几周跑照样有答案。
2026-08-07 沪深交易所《关于完善上市开放式基金相关安排的通知(征求意见稿)》: 商品期货 LOF、QDII LOF 最晚 2027-12-31 终止上市(设一年以上过渡期); 连续 60 个交易日场内资产净值低于 1000 万元的 LOF 也应终止上市,且不设过渡期。 涉及约 125 只、场内规模约 260 亿元,其中小规模 LOF 约 91 只、场内合计约 3 亿元。
这条线正好卡在本工具的选股逻辑上:场内盘子小 → 没人做套利 → 折价长期不收敛 → 被扫出来。所以加了两样东西。
一、场内规模(万) 进 metrics,触线的加一句提示
口径是 深交所日频场内份额 × 单位净值(fund_scale_daily_szse),不是拿日成交额猜。
成交额和场内资产净值是两回事——一只场内 3000 万的 LOF 完全可能一天只成交 5 万,
拿成交额去标"退市线候选"就是凭空造判断。两个必须知道的限制:
- 只覆盖深市(160-169 段)。沪市 501/502/505/506 这个接口没有,不标记 ≠ 安全。
- 单日快照,不是连续 60 日。只当预警,是否真触线以基金公告为准。
二、event_arb 加基金退出关键词——但走的是另一个库
stock_zh_a_disclosure_report_cninfo 的 market 参数决定查哪个巨潮库
(沪深京→szse / 基金→fund)。基金公告不在「沪深京」里,拿默认栏目搜"终止上市"
一条都搜不到,而且和"今天没有"长得一模一样——和配债栏那个 0 条是同一类陷阱。
所以 fund_keywords 单列,走 market="基金",动作词也另写一套
("读公告确认:退出方式、最后交易日、投资者选择期",而不是要约收购那套"对价 vs 现价")。
故意没放"基金份额持有人大会"和"转型"——太常见会刷屏。另加 max_items(默认 20)封顶。
方向提醒:退市对折价持有人是强制收敛(清盘按净值结算、转型有 20 个交易日 投资者选择期),未必是坏事。但那是一笔要扛数周净值波动的交易, 和本栏隔夜口径的净收益不能混着算——这句写进脚注了。
提示预算怎么平的。 加退市线这一句,就得按 v4.4 定的规矩删一句:删的是 「买卖价差 X%(bid/ask)」在滑点已触发时的重复——滑点那句已经印了 "最新价 0.97 / 卖一 0.975",价差句再印一遍只多一个买一和一个能推出来的百分比。 滑点没触发时(卖一==最新价)它仍是唯一的价差信息源,照印。
这一栏长期出 0 条,而报告只印一个"无",看不出是哪种:
- 真没标的:股权登记日 = 申购日 −1 个交易日,只有发行公告出来后才有值, 一只券在 10 天窗口里可见约 2-3 天,空窗完全正常。
- 列位移了:akshare 的
bond_zh_cov()是按位置给东财那 70 多列命名的 (big_df.columns = [...]一长串)。东财加一列,"原股东配售-股权登记日" 就静默接到相邻字段上——拿到的全是过去的日期,天天 0 条,且看起来完全正常。
分辨靠一条语义不变量:登记日应该在申购日前 1 天(跨周末最多 3 天)。 全表成立 → 列位对,0 条是真的;不成立 → 两列里至少一列接错了字段。
现在报告自己会说(--mock 之外的真实运行):
▎配债一手党 (0 条)
⚠ 本栏 0 条 = 窗口内确实没有登记日(接口正常,203 行有登记日)。
表里最晚的登记日是 09-17(XX转债),落在 08-17 的窗口之外
▎配债一手党 (0 条)
⚠ 本栏取数异常:列位可能移位:952 行里只有 0 行满足「登记日 = 申购日前 1 天」
⚠ 本栏 0 条**不可信** —— 跑 `python diag_allotment.py` 看实际列内容
有条目时这些取证说明一句都不印——报告不该为了自证而变长。
最需要警惕的不是报错,是「跑完了但缺了一块」。 典型场景:LOF 行情接口被东财 限流掐掉,报告照常生成,显示一堆 ETF 溢价、折价 0 条 —— 看上去像「今天没折价机会」, 实际是折价那一段根本没扫(场内显著折价基本都在 LOF 段)。
三道防线:
-
栏目标题下的红色告警。取数异常和口径问题直接印在对应栏目里, 而不是只堆在底部健康面板。空栏目会明说「0 条不代表没有机会」。
-
底部数据源健康面板。
扫描 N 行是判断接口是否真的取到数的依据。 -
退出码。挂 cron / 任务计划时按这个判断:
退出码 含义 0六个源都完整取到数 1报告已生成,但有源取数失败或结果残缺 2所有源都失败,报告没有参考价值 加
--exit-zero可强制返回 0。
遇到 ConnectionError: RemoteDisconnected 不必慌,多半是东财对连打全市场大表的
限流,过几分钟重跑即可:已成功的表还在 15 分钟缓存里,重跑只会重新请求失败的
那一个接口。
| 路 | 供应商 / host | 给什么 | 负责哪一侧 |
|---|---|---|---|
fund_etf_spot_em() |
东财 push2delay | IOPV 实时估值 | 跨境 / ETF 溢价 |
fund_etf_fund_daily_em() |
东财 fund.eastmoney.com | 市价 + 折价率(已公布净值口径) | 补 59 个 ETF 代码 + 符号对账基准 |
fund_etf_category_sina() × fund_etf_category_ths() |
新浪 × 同花顺 | 市价 × 单位净值 + 申赎状态 | LOF 折价 |
三条路分属三家供应商,一家挂了另两家通常还在。同一代码以先出现的为准。
v3 的注释写着「它覆盖全部场内基金(ETF/LOF/分级)」,探针实测把这句话推翻了:
fund_etf_fund_daily_em 1602 行 → LOF 段 0 个
「类型」列取值只有 指数型-股票/海外股票/固收/其他
相对 ETF 帧只多 59 个代码(159/511/551/561/520 段),其中 LOF 0 个
不是「LOF 的净值列为空」,是这个接口不收 LOF。 分清这两者很重要:前者会让人 去修解析、去换列名;后者说明再怎么修都没用,必须另找源。v3 的 LOF 覆盖门天天在 报警,而修法一直在给一口枯井换绳子。
fund_lof_spot_em() 同样不能用,两个独立原因:返回列里既没有 IOPV 也没有折价率
(只有行情:最新价/涨跌幅/成交量…),接口通了也算不出来;而且它走
88.push2.eastmoney.com,实测在本机网络下确定性不通(连上约 6 秒被掐,重试无效)。
但**「东财不通」要精确到 host** —— 同样是东财,push2delay 对 LOF 板块码是通的
(total=390),所以它留作市价的兜底,只是新浪覆盖更全(350 个)才排在后面。
同花顺给三列,实测(可解析率 / 与新浪市价的交集 / 折溢价中位数):
| 列 | 可解析 | 交集 | 中位数 | p95 | 溢价≥3% | 取舍 |
|---|---|---|---|---|---|---|
| 最新-单位净值 | 99.3% | 344 | -0.59% | +1.69% | 13 条 | 采用 |
| 当前-单位净值 | 90.6% | 308 | -0.63% | +0.62% | 5 条 | 逐行兜底 |
| 前一日-单位净值 | 98.0% | 342 | +0.35% | +5.33% | 35 条 | 不用 |
「最新」与「当前」在都有值的行上给出完全相同的折价(折价前 6 名逐位一致), 只是「最新」少 39 个空洞。
排除「前一日」的理由不是覆盖率,是口径错位:T-1 净值配 T 日市价,凭空吃进一天
净值漂移,溢价≥3% 从 13 条虚增到 35 条。危险之处在于它的中位数只有 +0.35%,
看起来「挺正常」—— 下面那道中位数体检抓不到它。所以代码里用的是净值列白名单
而不是模糊匹配「任何含『净值』的列」,这条在 selftest_fixes.py 里有专门的回归。
同花顺顺带给「赎回状态 / 申购状态」,这是前几版完全没有的信息,而它恰好是折价套利 的前置条件:赎回暂停时,折价只是买得便宜,不是可兑现的价差。实测 449 只里 42 只 赎回暂停,而且折价榜前列基本都是这一类(506008 科创板长城 -9.06%,申赎双暂停)。
只按幅度排序会让 max_discount=10 的配额被这些做不了的品种占满。所以折价侧改成
先按可赎回分组、组内再按幅度;暂停的照常列出但动作词改写成「赎回暂停,折价无法
通过赎回兑现(仅观察)」,不再说「折价买入」。溢价侧同理用申购状态。
「限制大额」对本项目的资金体量不构成约束,按可用处理,只打提示不降权。
首次实盘(2026-08-08)跑出 31 条折价,22 条赎回暂停 —— 9 条可赎回的最大折价只有 3.65%,而幅度榜前列(-9.06% / -7.98% / -7.32%)全在那 22 条里。分组排序这个决定 是被数据逼出来的,不是审美。
剩下这 9 条也不能直接当收益读。折价套利的路径是「场内买入 → 申请赎回 → 按当日净值 结算」,中间有三道减法,缺一道都会把折价说得比实际大。
① 买入价是卖一,不是最新价。 最新价只是最后一笔成交,可能成交在买一上 —— 161131 的最新价 1.001 恰好等于买一,而卖一是 1.015。
② 持有不足 7 日的赎回费,监管下限就是 1.5%(加佣金 0.03%)。
③ 能放多少钱由日成交额决定。 按单笔不超过成交额 5% 估,501217 一天成交 1.9 万, 仓位上限 950 元 —— 折价再真,绝对收益也只有个位数元。
三道减完,2026-08-08 那 9 条的次序全部改变(replay_20260808.py 可复现):
| 按幅度排 | 折价率 | → | 按元排 | 净收益 | 可投 | 预估 |
|---|---|---|---|---|---|---|
| 161131 | 3.65% | 160220 | 1.98% | 3.0 万 | 594 元 | |
| 160220 | 3.51% | 506005 | 0.74% | 3.0 万 | 222 元 | |
| 501220 | 3.17% | 165516 | 0.99% | 1.52 万 | 150 元 | |
| … | … | |||||
| 506005 | 2.27% | 161131 | 0.77% | 1.05 万 | 81 元 |
幅度第一的 161131 掉到第 6(卖一吃掉一半折价、一天只成交 21 万);幅度垫底的 506005 升到第 2(成交 1575 万,仓位放得满)。所以折价侧的档内排序从按幅度改成按元。
报告里印出来的三个数必须自己乘得通:可投 × 净收益 = 预估。首版拿未取整的中间值 算预估,8 条里 5 条对不上(3.0 万 × 1.98% = 594,却印 595),读者一验就不信这一栏了。 这层估算的假设误差远大于 1 元,那点精度不值得拿自洽去换。
v4.2 首版的回放把 165516 / 501098 的卖一写成了空,理由是上一版日志里它们「没有价差
提示」。实盘证明这是错的 —— 价差提示只在超过 spread_alert_pct(0.5%) 时才印,低于
阈值的价差照样存在、照样吃钱:这两只的滑点分别是 0.50 和 0.73 个百分点,各差
77 元和 85 元,足以改名次。提示没出现 ≠ 那个量为零 —— 这正是把滑点折进数值、
而不是只打一句提示的理由。
另有一类形态值得单独说:这两只都是最新价低于买一(收盘集合竞价价配盘中最后一档 报价),成交与盘口不是同一时点。数值仍按卖一算(悲观口径不因为快照可疑就放松), 但报告会明说「这条的滑点是按收盘盘口推的,开盘后要拿实时盘口重算」。
两处刻意悲观,别顺手优化回去:卖一低于最新价时不采信(陈旧/交叉快照),回落最新价; 即便用了卖一,5% 成交额去吃单仍会打穿好几档盘口,所以「预估(元)」是乐观值。 净收益 ≤ 0 的行不印「可投/预估」—— 给一笔不该做的交易配仓位,和给赎回暂停的行 印「扣费后 6.47%」是同一个毛病。
单条看「净收益 1.98%」像笔不错的买卖,铺满本金才看得出量级:10 万块按次序铺到第 6 条 就用完了,合计 1,243 元,约本金的 1.24%。
收益还高度集中:前 2 条就占 74%(816 元),后 4 条合计 281 元,而每条都是一次买入、 一次赎回申请、一笔资金占用到到账。报告会在集中度超过 60% 时把这句算出来。
更要紧的是它换的是什么。赎回按提交申请当日的净值结算,而报告里的折价是拿上一个 已公布净值算的 —— 中间隔着一整天的净值涨跌,权益类 LOF 一天动 1% 很常见,量级和 这份收益相当。这不是无风险的 1.24%。压掉这段的办法:盯住标的指数、14:45 之后当日净值 基本定型时再买入,并在当日 15:00 前把赎回提上去,别隔夜。
上一版留了个问题:折价扫出来一堆薄的,要不要把 discount_alert_pct 从 2.0 提到 3.0?
数据说不该 —— 提到 3.0 会砍掉 506005(2.27% 但 222 元,全场第二),却留下 161131
(3.65% 但 81 元)。要少看几条就用 min_profit_yuan(默认 0 = 不过滤):设成 100,
那天 9 条只剩 4 条,而且是对的 4 条。
v4.2 把滑点折进数值之后,上一版写的三句话原地变成了错的。这类回归比算错更隐蔽 —— 数字全对,话说反了:
| 位置 | 旧口径下对 | 新口径下错在哪 | 现在 |
|---|---|---|---|
| 价差提示 | 「折价要按你真正买到的价格重算」 | 价差已经扣过了,照做等于在 0.77% 上再扣 1.40%,读成负的 | 折价侧改说深度(卖一只是第一档,净收益是上限不是预期);溢价侧没做这层折算,保留原措辞 |
| 动作词 | 「折价 2.34% → 折价买入」 | 这条净收益 −0.98%,最显眼的一行却在说买 | 改成「扣掉滑点与赎回费后仅 −0.98%,兑现不划算(仅观察)」 |
| 组合合计 | 「够吃下前 6 条,合计 1,096 元」 | 第 6 条只填了 8,500 元(上限 12,900),把 6 条预估直接相加得 1,111,差 15 元 | 点名说明最后一条只填了一部分,以及为什么合计比直接相加少 |
三条的共同点:报告里的每个数都要能被读者验算,验不通就会连带着不信整栏。 这三处各有回归用例。
同一类故障(静默归零)在不同层次各设一道,因为上层的门拦不住下层的问题:
| 门 | 拦什么 | 为什么上一层拦不住 |
|---|---|---|
| 全局 | 一个标的都没算出来 | —— |
| 帧级 | 某一路扫了一堆行却一行都算不出折溢价 | 好的那帧会把总数撑起来 |
| LOF 分侧 | 市价缺 / 净值缺,分别报 | 合成一句「LOF 失败」等于下次重查一遍 |
| LOF join | 两边都取到但交集为 0(代码格式变了) | 表现和「今天没折价」一模一样 |
| 中位数体检 | 折溢价整体偏离 0 超过阈值 = 净值口径错了 | 前四道都只管「有没有数」,不管「数对不对」 |
还有一道方向校验:ETF 两帧对同一批境内标的会给出两套独立口径(IOPV 自算 vs
接口折价率),拿来对账正负约定。mean(|a-b|) 应当明显小于 mean(|a+b|),反过来就
说明「列名带『折价』就取反」这个假设已经不成立。这条很重要 —— 符号错了输出的不是
「没结果」,而是把「折价买入」和「溢价规避」精确地印反。跨境标的排除在外:它的
IOPV 与已公布净值本来就不同时点,方向天然可以不一致。
这也是 fund_etf_fund_daily_em 虽然不再承担 LOF 却没被删掉的原因:它是这套对账
唯一的第二口径来源。
东财给跨境标的的 IOPV 约等于上一期已公布净值,所以自算出来的溢价里混着海外市场
这段时间的涨跌,不是可直接交易的价差。一批同指数、不同管理人的基金同时显示 10%+
溢价时,既可能是 QDII 额度用尽导致的真实全行业溢价,也可能只是净值口径统一滞后 ——
光看这份报告分不出来。要判断只能拿集思录 T-1 估值或基金公司实时 IOPV 对一遍
(填 fund_premium.jisilu_cookie)。
pip install -r requirements.txt # akshare / pandas / PyYAML# 1) 离线自检:用内置样例走完整流程,先看清报告长什么样(不联网)
python run.py --mock
# 2) 实盘:联网抓取(需能正常访问东财/巨潮/集思录)
python run.py
# 指定输出格式 / 指定"今天"(回溯测试)
python run.py --format markdown html
python run.py --date 2026-08-08报告写到 ./reports/scan_YYYYMMDD.{md,html},终端也会打印。
⚠️ 联网环境:脚本抓的是东财/巨潮/集思录。受限网络(含本项目的构建沙箱)访问不到这些站点,实盘模式会在"数据源健康"里如实报错、但不会崩。请在你自己的机器/服务器上跑实盘。这也是当初建议你把这套放到 Claude Code 里边写边跑的原因——多数据源接入 + 定时任务,在本地迭代顺手得多。
关键几项:
capital/accounts:本金与账户数,用于占用/贡献估算。lookahead_days:打新/配债向前看几天。cb_ipo.pay_window_trading_days/cb_ipo.list_window_trading_days:缴款与上市提醒各提前几个交易日(都默认 2;是交易日不是日历天,否则周五跑会漏掉周一)。cb_allotment.max_prior_return_pct:正股 3 月涨幅阈值,默认 30。 (沪深最小配售单位不是配置项——那是交易所规则不是偏好,做成可调的等于给了改错的机会。)fund_premium.premium_alert_pct/discount_alert_pct:折溢价触发线。fund_premium.redeem_fee_pct/commission_pct:算「扣费后折价」的成本假设(默认 1.5 / 0.03)。fund_premium.min_turnover_wan/spread_alert_pct:流动性与买卖价差的提示线。fund_premium.only_redeemable:true时申赎暂停的品种整条不出(默认false,留着当"为什么便宜"的解释)。fund_premium.sanity_median_pct:LOF 折溢价中位数偏离 0 超过它就判净值口径不对。fund_premium.jisilu_cookie:填了才拉 QDII 实时估值溢价(更准)。event_arb.keywords:事件套利关键词,可加"吸收合并""控制权变更"等。sources.*:临时关掉某个源设false。
- 折价率符号:东财"折价率"的正负口径以官网为准。本模块统一按
溢价率 = -折价率处理。首次实盘请拿一只已知 QDII 核对方向(例如已知某标普 LOF 当前溢价,看脚本是否判为"溢价"),确认一次即可。 - 净值口径天然落后价格,盘中尤其:LOF 那路的市价是实时的、净值是已公布的,当日净值要收盘后才更新——盘中跑出来的折价含一个交易日的净值漂移,报告会按交易日差自动打标(收盘后或周末跑则两边同日,不打标)。跨境 QDII 更严重,结构性落后 1-2 个交易日;填
jisilu_cookie可拿集思录 T-1 估值溢价校准。 - 折价 ≠ 可兑现,更 ≠ 净收益:折价靠赎回兑现、溢价靠申购兑现;定开/封闭期产品在封闭期内天然折价,那是期限成本不是价差。能兑现的也要扣赎回费——持有<7日的下限 1.5%,会吃掉 2% 档折价的大半。报告已把申赎状态、扣费后折价、日成交额并排显示,别只看第一个百分比。
- 事件套利只给线索:套利空间 = 对价与现价之差,取决于要约价/现金选择权价/换股比例/税费/时间成本——必须点开公告核对,脚本不臆造收益数字。
- 配债这一栏的触发时点本身就偏晚(v5.0 补记):它靠"股权登记日"出条,而登记日只在发行公告之后才有值(通常只提前 2-3 个交易日)——那时正股多半已经高开。含权量与"跌 1% 需多少溢价打平"就是拿来量这件事的。真正的埋伏窗口在证监会核准到发行公告之间的 1-2 个月,那段数据不在
bond_zh_cov里,本工具看不到。 - 上市提醒只报时点:不印合理价值、预期涨幅、目标价这类没有出处的估值数。卖点自己定,工具的职责到"今天该看它"为止。
Linux/macOS(cron,每个交易日 09:10 跑一次)
10 9 * * 1-5 cd /path/to/cb_scanner && /usr/bin/python3 run.py >> reports/cron.log 2>&1Windows 计划任务
程序: python
参数: run.py
起始于: C:\path\to\cb_scanner
触发器: 每天 09:10(可设仅工作日)
推送已经做好了,不用你自己接。
notify.py是独立文件(不碰run.py/scanner/), 支持 Server酱、邮件、Bark、飞书、钉钉五个渠道,凭证一律读环境变量、不落盘。 分工是定死的:Server酱/Bark/飞书/钉钉 推摘要(手机横幅那一眼只要回答「今天要不要动手」), 邮件推全文(正文 markdown + HTML,两份附件)。python notify.py --selftest不联网跑 38 条断言,验解析器、渠道转换和发送次数。完整装配清单——定时开机 → 判交易日 → 跑批 → 推送 → 自动关机——见
SETUP_AUTORUN.md。那条链路里有一条贯穿全篇的设计原则: 推送没成功就不关机,让「机器第二天还亮着」成为唯一的异常信号。
这是"先建审计/运营层,再谈 beta 层"的第一块砖:
- 现在:让这六个确定性来源每天自动扫出来、不漏时点(尤其别漏缴款)。
- 跑稳 + 攒够半年真实收益后:你会有一个真实的"套利年化"分子,再回头反推 beta 层需要承担多少风险,而不是靠假设。
代码分层清晰(sources/ 每个源独立、单源失败不影响整体),后面要加"审计层"(已退市转债全生命周期、信号按公告落款日+1 对齐、换仓按次日开盘/VWAP)时,直接照 sources/base.py 的接口扩展即可。
这一节记的是探针推翻实现的一次,过程比结论有用。
cb_redeem 的第 ③ 档(在名单里但最后交易日为空)当初是为春23/应流设的:
docs/probes/probe.txt 显示这两只在 bond_cb_redeem_jsl 里有行、日期是空的,
而只按「日期在窗口内」出条会让它们静默消失(纪律 5 的头号敌人)。
实现时留了一个已知的未知:这一档到底会有多大——max_unknown: 20
先按「超了就截断展示、总数照说」处理,等 diag_redeem2.py 的 Q4 回答。
docs/probes/probe2.txt 回来了,数字是:319 行里带最后交易日的只有 6 行,其余 313 行为空(98%)。
这一下把原做法的前提掀了:「空」在这张表里不是「这只债的日期丢了」, 而是「这只债还没有最后交易日」——313 行空值不含任何个体信息量。 把它离线复演一遍(用 probe2 的真实 6 行 + 按实测比例铺开的 313 行), 后果是两条同时出现:
- 印出来的 20 条是代码最小那批噪音,天天占版面;
- 这一档本来要救的春23(113667)/应流(113697),恰好被截在配额外面。
也就是说它既灌了噪音,又没救到人,还让读者以为这一档有覆盖——比不印更糟。
改法:空占比 ≥ unknown_ratio_gate(默认 0.5)且是大表时,这一档一条都不逐行印,
改在栏目级说一句「319 行里只有 6 行带最后交易日,其余 313 行该列为空——
空不等于「没在赎回」」。少印可以,少说不可以;但「说」的形式不必是逐条占位。
空是少数派时(这一列大体有值、个别行缺)判据不变,那才是异常,照旧截断+报总数。
顺带两件由 probe2 直接改出来的事:
强赎天计数会带 HTML。临近到期那批的取值是临近到期 <span style="color:red;font-weight:bold;">!</span><br>… 最后交易—— 裸标签进 markdown 会显示成尖括号,进 html 会真的生效(红色加粗), 而报告里的字体权重是留给紧急度的。透传前过一遍strip_html:删标签,文本不动。- v5.1-rc 注释里「正帆 间隔 1684 天」那个强赎样例是错的。probe2 按代码回配 发现 118053 根本不在这张表里,第一次探针是按名称模糊匹配捞错了行。 删掉这条错例之后,这张表里一个远期样本都没有——这正是探针退出码 1 (分不开强赎与到期)的真实原因:不是缺列,是样本一边倒。
以上为研究路径与工程实现,不构成投资建议。所有百分比为数量级估算,实际结果取决于账户数、执行质量与市场热度。