抓取 Global X HSCEI Covered Call Active ETF 页面的完整持仓,生成摘要并邮件发送。
关键点:该基金页面的 "FULL HOLDINGS (.CSV)" 按钮是纯前端(
table2CSV.js), 把页面里已有的持仓表格在浏览器转成 CSV,没有后端下载链接。 所以本工具直接抓页面 HTML 解析那张表 —— 结果与点按钮导出的 CSV 等价,且更稳定。
-
抓页面(超时 + 重试 + 内容校验)
-
解析完整持仓表 + 期权敞口表(按表头列名认表,不依赖易变的 id),拆分正股 / 衍生品; 基金净值 NAV 与 HSCEI 收盘点位直接取页面公布值,「市值 ÷ 权重」反推值仅作兜底与校验
-
邮件发送:HTML 正文 + 纯文本备选 + 当日完整持仓 CSV 附件 (附件保留 UTF-8 BOM,Excel 双击打开不乱码;多个收件人走密送,不互相暴露地址)
邮件主题:
YYYY-MM-DD 3416.HK 持仓日报(日期为持仓截止日,代码取自FUND_NAME) 存在拦截项时加前缀【待补全】,补发的更新版加【更新】。正文内容:
- 抬头:持仓截止日期(有拦截项 / 补发时,上方另有一条醒目提示条)
- a. 期权腿明细:标题下副行为「指数现价 X, YYYY-MM-DD」;逐腿给出行权价、距离、到期日、 剩余天数、张数、IV、Delta、名义占净值(按剩余天数排序)
- b. 风险暴露:净方向性敞口一行,下接汇总表(名义占净值 / Delta 调整后)
与期货腿明细表:
- 正股敞口 ≈ 总正股市值 / 基金净值
- 期货多头敞口 = (合约张数 × 指数点 × 50) / 基金净值
- 期权空头敞口:名义 = Σ(合约张数 × 指数点 × 50) / 基金净值; Delta 调整 = 逐腿「该腿名义占比 × N(d1)」再求和, 每条腿的波动率各自由它自己的市价反解(见下文)
- 净方向性敞口 = 正股 + 期货 + 期权(Delta 调整)
- c. 前五大重仓占比 XX%(标题即合计占比,下接明细表)
- d. 快照差异:标题下副行为「YYYY-MM-DD1 vs YYYY-MM-DD2」;与上一份快照逐项对比、
只列显著不同,不对原始数据对错下结论:
- 汇总指标:NAV / HSCEI 收盘 / 正股市值合计 / 期货名义合计 / 期权空头总张数 (前日 / 当日 / 变化)
- 正股:新增 / 剔除全列;权重变化 ≥0.5pp 全列
- 期货、期权:逐腿新增 / 剔除 / 张数变化(期权按行权价+到期日对齐, 换仓滚动自然呈现为「一剔除一新增」,不加解读)
- 某一类无变动时不铺空表,只留一行说明
-
全程日志;失败发告警邮件;按持仓截止日期幂等去重(详见下文「调度与幂等」)
- HTML 与纯文本由同一份中间数据渲染,内容不会分叉:不支持 HTML 的客户端与通知预览 看到的是完整纯文本版,而非一句「请用 HTML 客户端查看」。
- 页面抓取的文本(证券名、合约名、告警文案)在渲染时统一做 HTML 转义,
名称里的
&、<不会破坏邮件结构。 - 表格样式内联;数值列与表头同向对齐,增加 / 减少用颜色区分,正文限宽 900px。
- 移动端(≤480px)通过
<style>里的@media把多列表转为「标签:值」卡片式 (iOS 自带 Mail、Gmail App 支持良好);QQ 邮箱 App / Outlook 等忽略<style>的客户端自动回退为普通表格 —— 此时数值列不再强制不换行,页面不会横向溢出。 - 小节标题带下划线;节副行(如「指数现价 8,490.31, 2026-08-27」「2026-08-26 vs 2026-08-27」)以浅灰小字呈现,与标题区分。
- 邮件头带
Auto-Submitted: auto-generated与X-Auto-Response-Suppress: All, 不会被自动回复(外出/休假)来回触发。
- 敞口来源以「完整持仓表」的合约行为准,官网那张「期权敞口表」只作并列对照:
两个来源的名义占比都会写进日报注脚,只给数字、不设阈值、不下"漏腿/不符"的结论。
原因:两张表更新不同步,新加的腿(如 2026-08 新纳入的
CALL HSCEI WEEKLY OPTION) 可能晚一步才进敞口表,拿它当主源会静默漏算;持仓表则同时给出张数和期权市价。 - 工具识别集中在三条规则(
EQUITY_TICKER_RE/FUTURE_RE/OPTION_RE), 基金新增工具类型只改这三行。行权价与到期日一律从合约名称解析,已覆盖... WEEKLY OPTION 08/07/26 8750、... 08/28/26 C8700 OTC(页面会把C7700渲染断开成C870 0)、... HANG SENG CHINA ENT 08/28/26 8700等写法。 - Delta 不用拍脑袋的假设波动率:持仓表里的
Market Price就是期权的指数点报价, 先由它反解隐含波动率(二分法),再算 Black-ScholesN(d1)。反解不出来才退回IMPLIED_VOL(默认 30%),且会在日志和正文里标明。实测差异不小:2026-07-31 那天市价反解出的 IV 是 21.1% / 21.6%, 而非假设的 30%,「Delta 调整后」由 -45.04% 修正为 -42.72%。
- 算不出剩余期限的腿一律不参与计算。
T<=0时 BS Delta 会退化成 「实值 1 / 虚值 0」的阶跃值,所以这些情况必须先拦住,否则那一格会静默变成 0.00% 或直接等于名义值:合约已过期却仍挂在页面上、到期日解析不出、持仓截止日期解析不出。 - 任何一条腿被丢掉都会拦截发送(张数缺失、行权价解析失败、行权价与指数明显不匹配、
看跌期权……)。丢腿只会让敞口偏小,而偏小的百分比长得和正常日报一模一样 ——
所以它必须进邮件提示条,而不是只进
run.log(CI 的日志随 runner 销毁,且被 gitignore)。
脚本对原始持仓数据不做正确/错误的推断:不再与"基准快照"比行数/张数, 也不再输出"漏腿""换仓""数据不全"之类的结论。与前一日快照的一切数量变化 (正股增删、权重涨跌、期货/期权换仓滚动、张数增减)都如实列进日报的 「快照差异」一节,是增是减、孰大孰小由读者自行判断。
只有两类事实会拦截发送(红色提示条 + 【待补全】前缀 + 允许补发):
| 情况 | 行为 |
|---|---|
| 某条腿无法纳入敞口计算(张数缺失、行权价解析不出、看跌腿等),或有权重腿但缺指数点位 | 属于"算不出来":若等一等可能就好(如页面渲染问题)→ 先不发信、不写幂等标记,10 分钟后重试;等也没用 → 立即发带提示条的日报 |
| 整类工具相对前一日消失(如昨日有 N 条期权合约行、今日为 0) | 官网分阶段发布(先放正股、稍后补衍生品行)的硬信号:同上先等待;超过 INCOMPLETE_GRACE_HOURS(默认 3 小时)→ 发一封 【待补全】 兜底 |
| 之后数据补齐 | 自动补发一封 【更新】,每个截止日最多补一次 |
页面解析不出 As of 日期,或截止日期比已发过的还早 |
不发日报(去重键会失效 → 会按 cron 频率刷屏);前者走按天去重的失败告警邮件,后者静默跳过 |
对比用的快照元数据(NAV / 指数收盘)随当日快照一起存进
data/holdings_YYYYMMDD.meta.json,旧快照没有该文件时对应指标显示 N/A。
官网持仓是「隔日更新」,且当天具体几点更新不固定。为尽快收到、又不重复发, 采用「高频触发 + 按日期去重」:
- 触发:工作日每 10 分钟跑一次(见部署)。
- 每次跑都读页面上的
As of <日期>作为「持仓截止日」,与状态文件data/state.json里last_as_of比对:- 日期没更新 → 直接跳过:不发邮件、不存快照、工作流仍成功(
nothing to commit)。 - 日期推进到新交易日 → 发一封(带「快照差异」),并把新日期写回
state.json。 - 存在拦截项时先等待重试(见上节),数据补齐后自动补发
【更新】。
- 日期没更新 → 直接跳过:不发邮件、不存快照、工作流仍成功(
- 净效果:数据更新后最多 10 分钟内收到,且每个交易日最多一封。
- 调试强制重发:
FORCE_SEND=1(忽略去重)。
高频触发下,故障也不会刷屏 —— 两层告警都按天去重:
- 脚本层(SMTP 告警):任一步异常 → 发一封失败邮件;同一天只发一次
(
state.json的last_alert_date)。 - 工作流层(GitHub Issue):
if: failure()时自动建一个 Issue(每天最多一个), 覆盖"连告警邮件都发不出去 / Runner 自身挂了"等脚本兜不住的情况; GitHub 会就该 Issue 通知你。需issues: write权限(workflow 已声明)。
pip install -r requirements.txt
pip install -r requirements-dev.txt
cp .env.example .env # 填好邮箱授权码和收件人
set -a; source .env; set +a # 加载环境变量
python -m daily_holdings快照、日志、状态都在 data/ 下。调试想强制重发:FORCE_SEND=1 python -m daily_holdings
SMTP_PASS 填的是授权码,不是登录密码:
QQ邮箱 → 设置 → 账号 → 开启 IMAP/SMTP → 生成授权码。
SMTP_HOST=smtp.qq.com,SMTP_PORT=465(SSL)。
当前部署在 公开仓库(公开仓库的 Actions 额度无限,故可每 10 分钟跑; 私有仓库免费额度 2,000 分钟/月,高频会不够)。授权码等放加密 Secret,公开不泄露。
- 推到一个 公开 GitHub 仓库。
- 仓库 Settings → Secrets and variables → Actions,加这 6 个 Secret:
SMTP_HOSTSMTP_PORTSMTP_USERSMTP_PASSMAIL_FROMMAIL_TO(FUND_NAME、FUND_URL非敏感,直接写在 workflow 的env:里,改基金只改这两行。) .github/workflows/daily.yml已配好:- 定时
*/10 3-15 * * 1-5(UTC)= 工作日 港时 11:00–23:50 每 10 分钟; - 也可在 Actions 页面点 "Run workflow" 手动触发;
- 跑完把当日快照(
data/holdings_*.csv、state.json)提交回仓库,供次日对比。
- 定时
- 用
gh一键创建并配置(参考)::gh repo create <name> --public --source=. --push set -a; source .env; set +a for k in SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS MAIL_FROM MAIL_TO; do gh secret set "$k" --body "${!k}"; done gh workflow run daily-holdings # 手动触发一次验证
注:GitHub 定时任务不保证准点,高峰期可能延迟几分钟或偶尔跳过;配合幂等去重不影响结果。
想改用自己的服务器(如 Oracle Cloud 永久免费机)也行,等价 crontab:
*/10 11-23 * * 1-5 cd /path/app && set -a && . ./.env && set +a && /path/venv/bin/python -m daily_holdings