自建美股期权新挂牌收益率监控:从选型到树莓派部署
记录一次从"随手问问"到"跑起来的树莓派服务"的完整过程:期权数据源怎么选、 期权价格构成和Delta/IV这些指标到底怎么用、新挂牌合约监控系统怎么设计、 上线后踩的几类坑(计算逻辑、产品设计、运行环境、部署运维)、一次真实 历史数据回测的探索过程(yfinance/IBKR碰壁,MarketData.app走通)、 从历史统计到"单持仓vs历史基准"对比工具、独立于通用筛选的重点关注清单, 以及完整的部署和日常运维步骤。附可直接照抄的配置和命令。
起点:一个简单的需求
最初的想法很朴素:卖美股期权(covered call / cash-secured put)的时候, 想要一个自动化的收益率追踪工具——输入股票代码、行权价、方向(卖put还是 卖call)、目标年化收益率,系统自动跟踪报价,一旦达到目标就发Telegram通知, 内容包括触发时间、当前报价、方向、收益率。
这个需求本身不复杂,年化收益率公式也很直接:
sell_put: annual_yield = premium / strike * (365 / days_to_expiry)
sell_call: annual_yield = premium / underlying_price * (365 / days_to_expiry)
(premium建议用bid价,不要用mid价——流动性差的合约mid价容易虚高, 后面会讲到为什么。)
真正花时间的地方,是把"数据从哪来"这件事想清楚。
数据源选型:一圈比较下来
选型过程大致是这样的排除法:
| 数据源 | 免费性 | 维护成本 | 结论 |
|---|---|---|---|
| IBKR API | 已有账户免费 | 需要TWS/Gateway常驻,运维重 | 数据最准,但网关断连要自己处理重连 |
| Tradier | 实盘账户免费拿实时数据 | REST接口,不需要常驻网关 | 免费层是sandbox 15分钟延迟,实盘账户才是真实时 |
| Alpaca Basic | $0,开paper账户即可 | WebSocket推送,维护成本最低 | 数据是indicative(非OPRA实时NBBO),仅供参考不能直接当挂单价 |
| Polygon.io(已更名Massive.com) | 免费层有限 | 按量付费 | 适合历史数据/回测,不建议做V1实时监控 |
| 富途OpenAPI | 期权行情需单独付费(约$60/月) | 需要常驻OpenD网关 | 对个人监控场景不划算 |
| 雪盈证券 | 开户送30天试用,之后要交易期权才持续免费 | 底层走IBKR清算 | 不适合"只监控不下单"的用法 |
| yfinance(非官方) | 完全免费,零注册 | 零维护,但随时可能被限流/改版 | 适合先验证逻辑,不建议长期依赖 |
最后选定的策略:先用yfinance把整套逻辑验证通,未来想长期稳定跑, 再迁移到Tradier或Alpaca。数据源在代码里做成了一个可替换的接口 (QuoteProvider),换源不用动上层业务逻辑。
期权市场的几个"反直觉"机制
设计系统之前,先弄清楚了几个容易搞混的市场机制:
1. 头寸建立是实时的,钱到账不是
下单卖出期权后,持仓立刻反映在账户里,但权利金的实际结算是T+1—— 买方经纪商在T+1把保费付给OCC(期权清算公司),OCC再转给你的经纪商, 最后才计入可用现金。所以"已成交"和"现金到账"是两个不同的时间点, 中间那段时间的状态标记(比如App里的一个小蓝点)代表的就是"已成交、未结算"。
2. 大单的对手方几乎总是做市商,不是"对赌者"
像段永平那种量级的卖put大单,接盘的几乎肯定是做市商——它存在的意义是 持续报双边价格提供流动性,赚的是买卖价差,不是在判断方向。接单之后, 做市商会立刻去正股市场做delta对冲,把方向性风险冲掉,留下的主要是 价差收益。这也是为什么大单能顺利成交,不需要"刚好有个体量相当的对手"。
3. 新挂牌合约头几天报价偏"贵",主要是这几个原因叠加
- 标的本身的隐含波动率(IV)才是定价的核心变量,波动大的题材股本身就贵
- 新合约流动性差,做市商为了对冲逆向选择风险,会主动把价差报宽、IV报高, 随着交易量起来会逐渐收窄
- Put偏度(skew):同样虚值程度下,put通常比call贵,因为市场对下跌保护 的需求更大
4. 美股期权盘前盘后基本不交易——这跟股票不一样
股票在很多券商那里盘前盘后都能挂单成交,但期权不是。FINRA的说法很直接: 股票期权通常不在盘前盘后交易,只有极少数合约例外。这不是某个数据源的 限制,是期权交易所本身的运作时段问题——9:30 ET之前,做市商基本不报价, 查到的bid/ask大概率是0或空值。
唯一的例外是2026年才有的:Cboe拿到SEC批准,从7月起给少数几个"交易量和 流动性排名靠前"的标的试点盘前盘后交易,但覆盖范围很窄,冷门的小盘题材股 基本不在名单里。踩过一次坑:手动在西班牙时间13点多(约等于美东7点多, 开盘前)跑监控脚本,查到的整条期权链bid/ask全是0,一度以为是数据源或 代码出了问题,排查半天才确认是"这个时间点期权市场根本没开始报价", 不是bug。
系统设计:从"固定合约监控"到"新挂牌自动发现"
最初的设计是固定的 symbol + strike + expiry 组合,达到目标收益率就通知。 后来发现真正想抓的是"新挂牌合约头几天价格好"这个窗口期,于是加了一层 新挂牌合约自动发现:
每天(开盘后30分钟):
对watchlist里每个symbol拉取完整期权链
跟本地快照做diff → 找出今天新增的合约
对每个新增合约算年化收益率
按阈值过滤后发Telegram通知
把新合约加入"追踪队列",接下来5天持续记录报价
三张表的设计(后来加了第四张,见后文)
-- 快照表:只存"这个合约今天存在",用于每日diff的基准线
option_snapshot (symbol, expiry, strike, option_type, first_seen_date)
-- 追踪队列:哪些新合约还在观察期内
tracking_queue (symbol, expiry, strike, option_type, added_date, track_until)
-- 时序追踪表:每次轮询追加一行,用于画衰减曲线
contract_tracking (symbol, expiry, strike, option_type, poll_timestamp,
bid, ask, mid, underlying_price, days_to_expiry, annual_yield)
拆成三张表而不是一张的原因:快照表要保持精简(只用于diff);追踪队列 控制"哪些合约还在5天观察期内",避免轮询逻辑到处判断日期;追踪表是 只追加(INSERT ONLY)的时间序列,专门留给后续导出画图用。
规模上(watchlist几十个symbol,重点追踪10多个),这个量级SQLite完全够用, 评估下来不需要上TimescaleDB——真正该换的临界点是symbol数量涨到几百个、 或者观察期从5天变成无限期追踪,数据到千万行级别的时候。
项目结构
option-monitor/
├── config/watchlist.yaml # 所有配置都在这里,改完不用重启
├── db/schema.sql # 四张表的定义
├── deploy/systemd/ # service + timer 文件
├── src/option_monitor/
│ ├── config.py # 读配置
│ ├── db.py # SQLite读写
│ ├── quotes.py # 行情源抽象层(换数据源只改这里)
│ ├── yield_calc.py # 年化收益率 + 内在/外在价值
│ ├── greeks.py # Black-Scholes Delta
│ ├── evaluate.py # 共享的评估+过滤逻辑
│ ├── notify.py # Telegram通知和排版
│ ├── discover.py # 每日新合约发现
│ ├── track.py # 盘中扫描 + 追踪队列维护
│ ├── export_chart.py # 衰减曲线导出
│ └── debug_chain.py # 诊断脚本
└── requirements.txt
有几个模块是踩坑之后才拆出来的:evaluate.py(把过滤逻辑从两个脚本里 抽出来共用,避免两边写岔)、greeks.py(Delta计算)、debug_chain.py (排查数据问题时临时写的,后来发现很好用就留下了)。
通知设计:从"一条条发"到"汇总+错误通知"
最初是每发现一个新合约就发一条Telegram消息,watchlist标的一多容易刷屏, 后来加了两个改进:
notify_mode: summary:一次运行的所有达标合约汇总成一条消息, 按年化收益率降序排列;消息超过Telegram单条4096字符上限时自动按行拆分- 最小年化收益率过滤(
min_annual_yield):只控制要不要通知,不影响追踪 ——所有新合约不管年化多少都继续记录报价,保证衰减曲线的样本量不会因为 过滤通知而变少 - 失败也要通知:拉取期权链失败、拿不到现价这类错误,运行结束后额外发 一条Telegram错误汇总,而不是只写进日志等着没人看;脚本崩溃时也会先发一条 “🔥崩溃"通知再退出,配合systemd的
Restart=on-failure自动重试一次
衰减曲线导出
追踪表积累几天数据后,可以导出一张图,把所有被追踪合约按"发现后第几天” 对齐叠加,浅色细线画个体、红色粗线画中位数,直观回答"新合约价格衰减到 第几天趋于平稳"这个最初的观察。
先补一课:期权价格到底由什么构成
后面的过滤逻辑全建立在这几个概念上,先说清楚。
内在价值 vs 外在价值
期权价格(bid/ask)= 内在价值 + 外在价值
- 内在价值:现在立刻行权能拿到多少钱,纯数学计算,跟波动率、时间无关
- Put:
max(行权价 - 现价, 0) - Call:
max(现价 - 行权价, 0)
- Put:
- 外在价值:期权价格减掉内在价值剩下的部分,这才是卖方真正靠承担风险 和等待时间赚到的钱
举个前面踩坑时的真实例子:USAR现价$15.6,行权价$2的call,bid报$15.0。 内在价值是 15.6 - 2 = 13.6,外在价值只有 15.0 - 13.6 = 1.4。拿整个 $15.0当"权利金"算年化,等于把"行权时本来就要还回去的钱"也算成了收益。
ITM / OTM / ATM
- ITM(价内):内在价值 > 0。put行权价高于现价,或call行权价低于现价
- OTM(价外):内在价值 = 0,期权价格100%是外在价值
- ATM(平值):行权价 ≈ 现价
对"卖期权收租"这个策略,通常只该关注OTM或接近ATM的合约——OTM的价格全是 时间价值,赚的就是这部分,逻辑干净。深度ITM的合约本质是"预期会被行权, 提前拿点补偿",不是收租。
Theta衰减
外在价值随时间流逝一直往0掉,到期日必然归零(只剩内在价值)。这个过程叫 Theta衰减——“新合约头几天价格好、后面慢慢降"这个最初的观察,本质就是 Theta衰减在起作用。
IV(隐含波动率)
外在价值主要由两个因素决定:到期天数和IV。IV是"市场认为这只股票 接下来波动有多大”,IV越高期权越贵。USAR/CRML这种小盘题材股IV能到70-110%, 蓝筹股通常只有20-30%——这就是为什么题材股的期权权利金看起来特别诱人。
Delta
粗略理解成"这个期权到期时变成价内的概率"(不完全精确但方便记)。 call的Delta在[0,1],put在[-1,0]。卖方常用Delta挑行权价而不是直接挑价格:
| Delta绝对值 | 含义 | 适合谁 |
|---|---|---|
| 0.15-0.30 | 约15-30%概率被行权 | 保守收租,主流选择 |
| 0.30-0.50 | 权利金更高,被指派概率明显上升 | 更激进 |
| ≈0.50 | 平值,基本是抛硬币 | 年化好看但风险高 |
上线后踩的一个真实的坑:年化收益率算出8796%
系统跑起来第一天,Telegram汇总消息里出现过这样的数字:一个行权价$2的 USAR call,年化收益率8796%。排查下来是两个问题叠加:
问题一:分子用错了。原来的公式直接拿整个bid价当"权利金"算年化—— 按前面说的拆解,USAR现价$15.6、行权价$2的call,bid $15.0里有$13.6是 内在价值,只有$1.4是真正的外在价值。用整个$15.0当分子,等于把"行权时 本来就要还回去的钱"也算成了收益。
问题二:到期天数太短,年化公式的放大倍数失控。年化要乘以 365/剩余天数,到期只剩4天时这个乘数是91倍,一点点数字都会被放大成 天文数字。
修复方案:
intrinsic = max(strike - underlying_price, 0) # put
# 或 max(underlying_price - strike, 0) # call
extrinsic = max(premium - intrinsic, 0)
annual_yield = extrinsic / base * (365 / days_to_expiry)
分子换成外在价值后,深度价内合约会数学上自动收敛到接近0%,不需要额外 设阈值过滤;再加一个min_days_to_expiry(默认7天),到期太近的合约 直接跳过,不计算不通知。用实际报出来的错误数据反向验证过,修复后数字 恢复正常。
免费顺手加的指标:IV / Volume / OI / 价差 / Delta
修完那个bug之后,顺带把yfinance返回数据里本来就有、但之前没存的几个 字段接了进来,零额外成本:
- 隐含波动率(IV) 和 成交量/未平仓量(Volume/OI):yfinance的期权链 数据本来就带这几列,之前直接被丢弃了
- 买卖价差占比:
(ask-bid)/mid,量化"这个报价流动性好不好" - Delta:用Black-Scholes公式自己算(只用Python自带的
math.erf, 不用装scipy),可以按"到期变价内的概率"筛合约,比纯粹按价格挑更专业
OI为0或价差超过50%时,Telegram通知会自动标一个⚠️流动性差——这是对 “深度ITM那次教训"的直接呼应:报价不可信的合约,光看bid数字是看不出来的, 得结合OI和价差一起判断。
另一个数据坑:bid和ask同时为0,不代表价格真的是0
用诊断脚本查真实数据时撞见过两次:一次是盘前整条期权链bid/ask全是0(前面 提过的"期权盘前不交易”),另一次是远期LEAPS(到期494天)里,大部分行权价 也是0/0,只有少数几个"主力"行权价有正常报价——这是远期LEAPS的正常特征, 做市商只会持续盯着少数几个行权价报价,其余的技术上"存在"但没人做市。
两次现象说明一件事:bid和ask同时精确等于0,几乎总是"没有报价",不是 “报价就是0”。真正在做市的合约,就算深度虚值快归零,通常也会挂个最小价位 (比如$0.01),不会两边都挂0。原来的代码没做这个区分,把"没有数据"当成 “数据是0"存进了追踪表,会悄悄污染后续的收益率计算和衰减曲线的样本。
修复很直接:解析报价时做一次归一化,bid和ask同时为0就转成None(缺失值), 走跟"报价本来就缺失"一样的处理路径:
def _normalize_quote(bid, ask):
if bid == 0.0 and ask == 0.0:
return None, None
return bid, ask
从"只通知新合约"到"交易时段分阶段通知”
系统跑了几天后发现一个设计缺陷:discover.py一天只跑一次,只通知"新合约", 不满足"新"这个条件的老合约,就算收益率再高也不会被通知——结果就是交易 时段大部分时间看起来"什么都没发生",容易让人以为程序没在正常工作。
track.py原来的角色只是"给追踪队列里的合约默默记录报价",不发现新机会 也不通知。重新设计成两件事都做:
- 追踪队列维护(老功能不变):继续给观察期内的合约记录报价,给衰减 曲线用
- 盘中全链扫描通知(新功能):每次运行都把watchlist里每个symbol的 完整期权链扫一遍,不限于"新合约",只要当前满足收益率门槛就通知
两件事共用同一次期权链请求,不重复拉数据。
去重是这次的关键:如果不做任何去重,track.py每小时跑一次,同一个 持续达标的合约会被重复通知7遍,体验比不通知还差。加了一张按天分区的表:
CREATE TABLE daily_notification_log (
symbol, expiry, strike, option_type, notified_date,
PRIMARY KEY (symbol, expiry, strike, option_type, notified_date)
);
discover.py和track.py共用这张表判断"今天有没有通知过"——不管是被 “新合约"逻辑通知的还是被"盘中扫描"通知的,同一个合约同一天只会触发一次 消息,第二天notified_date变了自动"重置”。两种通知在Telegram里标题不同, 一眼能分清是哪种:🆕 新挂牌合约汇总来自discover,🎯 盘中扫描达标来自track。
track.timer的起始时间也从16点挪到了17点(比discover晚一小时),避免 两个systemd服务同一分钟并发读写这张去重表造成竞态,也保证discover先跑完, 当天第一批"新合约"通知总是从discover发出,track只补discover漏掉的老合约 机会。
代价是track.py现在请求量从一天一次变成一天七次,对yfinance这种非官方 接口的压力明显上升——watchlist规模大了以后如果开始碰到限流,这是该考虑 换Tradier或Alpaca的信号。
第二个设计缺陷:241条通知,等于没有通知
盘中扫描上线后,某天的Telegram消息是这样的:
🎯 盘中扫描达标 (2026-09-15 15:00 UTC)
共 241 个合约当前满足年化门槛
• CRML 2026-09-25 6.5 卖put — bid 0.45 / 年化 176.21% / IV 107.8% / Delta -0.509 / OI 1347
• CRML 2026-09-25 6.5 卖call — bid 0.3 / 年化 172.07% / IV 104.7% / Delta 0.4887 / OI 155
• USAR 2026-09-25 15.5 卖call — bid 0.72 / 年化 171.32% / IV 77.7% / Delta 0.4973 / OI 41
...(还有238条)
241条根本没法看。但更关键的问题不是"排版难看",而是这些合约大部分本来 就不该出现在通知里——注意看Delta那一列:-0.509、0.4887、0.4973,全是 平值(ATM)合约。年化176%确实是真的,但那是靠承担约50%被行权概率换来的, 不是"收租",更接近赌方向。
根源是min_annual_yield=0.20这个门槛,对IV 70-110%的高波动股完全没有 过滤力——这类标的随便一个平值合约年化都上百%,20%的门槛等于没设。
解法:加Delta / OI / 每标的数量三道过滤
# Delta绝对值区间,核心过滤——只看真正"收租"区间,排除ATM赌博
min_abs_delta: 0.15
max_abs_delta: 0.30
# 未平仓量下限,干掉OI=0、OI=2这种没人交易的合约
# (之前只在消息里标⚠️流动性差,不拦截,现在直接过滤掉)
min_open_interest: 50
# 每个标的最多通知几条(按年化降序取前N),避免单个标的刷满整条消息
max_per_symbol: 5
实测这三道过滤下去,241条能压到个位数或十几条。
两个实现细节值得记一下:
Delta算不出来时不拦截。IV字段偶尔缺失会导致Delta算不出来(返回None), 这种情况选择放行而不是拦截——避免因为数据源偶尔缺字段就漏掉真正的机会, 消息里会显示ΔN/A,自己判断。
被数量上限截掉的合约也要标记成"今天已通知"。否则下一轮track.py跑的 时候,它们会被当成"还没通知过"重新冒出来,等于变相绕过了截断——这个坑 不写测试很难发现。
展示:按标的分组,每合约两行
排版上试过几种方案,最后选了按标的分组而不是"按价格/年化区间分档"—— 因为实际决策是在"这个标的要不要卖"这个层面做的,不是横向比不同标的。
🎯 盘中扫描达标 (2026-09-15 17:00 UTC)
共 6 个合约当前满足门槛
筛选: 年化≥20% | Delta 0.15-0.3 | OI≥50
📊 CRML (现价 $6.43)
• 10-09 | 5.5 卖put
年化 88.4% | bid 0.2 | Δ-0.24 | IV 105.0% | OI 913
• 10-23 | 5.0 卖put
年化 61.2% | bid 0.25 | Δ-0.18 | IV 98.0% | OI 487
📊 USAR (现价 $15.42)
• 10-16 | 13.0 卖put
年化 62.3% | bid 0.45 | Δ-0.22 | IV 72.0% | OI 4477
几个设计考虑:
- 分组标题带现价:一眼能判断行权价离现价多远,不用心算
- 组间按"该组最高年化"降序:机会最好的标的排最前面
- 每个合约占两行而不是挤成一行:手机上不用横向滚动
- 到期日只显示月-日:年份对判断没帮助,省宽度
- 顶部显示当前筛选条件:几个月后翻旧通知,能立刻知道当时门槛是什么
部署:树莓派 + systemd + uv
定时任务:systemd timer 而不是裸cron
用systemd的好处是日志走journalctl、失败按Restart=on-failure自动重试, 比cron好排查问题:
[Service]
Type=oneshot
ExecStart=/home/pi/option-monitor/.venv/bin/python3 -m src.option_monitor.discover
Restart=on-failure
RestartSec=60
时间点直接写死成西班牙本地时区,不改树莓派系统时区、也不用处理 夏令时切换。美东和西班牙的时差一年里绝大部分时间固定是6小时(两边DST 偏移量刚好抵消),只有每年春秋DST切换日期错开的那2-3周窗口期会变成 5小时——对这种"看大方向"的监控场景,早跑/晚跑一小时完全无所谓, 不值得为了处理这个边界情况去动系统时区或写夏令时判断逻辑。按6小时 固定换算:开盘9:30 ET对应西班牙时间15:30左右,discover任务设在16:00 (开盘后30分钟),track任务从17点跑到22点(比discover晚一小时起步, 原因见前面"去重"那节——避免两边并发写同一张去重表撞车)。
环境管理:venv+pip 还是 uv?
按"库依赖 vs 命令行工具"的判断框架,option-monitor属于前者 (代码里import yfinance),标准方案是venv+pip。但既然uv只有好处没有代价:
- 速度:Rust实现,创建venv用硬链接而不是复制文件,包安装/解压走异步并行, 装PyTorch这种大包能从几分钟缩到几秒
- “占用大"这个顾虑不适用于这个场景:uv只有在需要下载一个系统没有的 特定Python版本时才会真的占用额外磁盘(比如另一个项目pdf2zh要求3.10-3.12 而系统是3.14的情况);option-monitor对Python版本没有硬性要求,
uv venv会直接复用已有的系统Python,不会触发额外下载
最终流程:
uv venv # 生成 .venv,复用系统Python
source .venv/bin/activate
uv pip install -r requirements.txt
代码更新:rsync + oneshot服务,不用重启
日常改完代码用rsync同步到树莓派,因为discover/track都是systemd的 oneshot服务(不是常驻进程),下次timer触发就是全新启动一个Python 进程,自动读磁盘上最新的代码,不需要重启服务。
rsync要小心排除几个只存在于树莓派本地、本地开发目录没有的东西—— .venv/、.env、*.sqlite3、logs/,不然带--delete的同步会把 数据库和密钥当成"源端没有的多余文件"删掉:
rsync -avz --delete \
--exclude='.venv/' --exclude='.env' \
--exclude='*.sqlite3' --exclude='logs/' \
./option-monitor/ pi@树莓派IP:/home/pi/option-monitor/
依赖变化不会被rsync自动安装,加了新依赖记得同步完手动跑一遍 uv pip install -r requirements.txt;schema如果是新增表(比如这次的 daily_notification_log),CREATE TABLE IF NOT EXISTS下次运行会自动 建出来不用管;但如果是给已有表加列(比如之前给contract_tracking加 IV/Delta那几列),IF NOT EXISTS对已存在的表不会生效,还是得手动删库 重建。
完整部署步骤(从零开始)
1. 准备Telegram Bot
- 在Telegram里找
@BotFather,发/newbot,按提示起名,拿到 bot token(形如123456789:AAH...) - 给你的新bot发一条任意消息(bot不能主动给陌生人发消息,必须先由你发起)
- 浏览器打开
https://api.telegram.org/bot<你的token>/getUpdates, 在返回的JSON里找"chat":{"id":123456789},这个数字就是 chat ID
2. 树莓派上拉代码、建环境
cd /home/pi
# 把项目放到这里(rsync/scp/git clone 都行)
cd option-monitor
# 装uv(如果还没装)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv # 生成 .venv,复用系统Python
source .venv/bin/activate
uv pip install -r requirements.txt
3. 配置密钥和watchlist
cp .env.example .env
nano .env
填入刚才拿到的两个值:
TELEGRAM_BOT_TOKEN=123456789:AAH...
TELEGRAM_CHAT_ID=123456789
然后编辑 config/watchlist.yaml,最少改 symbols 这一项填上你要监控的 标的,其余保持默认即可:
symbols:
- USAR
- CRML
- NB
4. 先手动跑一次,确认能通
# 先看看某个标的的期权链数据正不正常(不写数据库,纯诊断)
.venv/bin/python3 -m src.option_monitor.debug_chain --symbol USAR --min-dte 7
# 确认数据没问题后,手动跑一次发现任务
.venv/bin/python3 -m src.option_monitor.discover
注意跑的时间:必须在美股交易时段内(西班牙时间约15:30-22:00), 盘前跑会查到整条期权链bid/ask全是0——前面踩过这个坑。
第一次跑会通知一大堆:快照表是空的,watchlist里所有合约都会被当成 “新合约”。这是正常的,可以把它当作一次"预热”,从第二天开始通知才有意义。
5. 装systemd定时任务
# unit文件里的路径默认是 /home/pi/option-monitor,跟你实际路径不一致的话先改
sudo cp deploy/systemd/*.service deploy/systemd/*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now option-discover.timer
sudo systemctl enable --now option-track.timer
# 确认timer已经在计划中
systemctl list-timers | grep option
6. 日常运维命令
# 看日志(-f 实时跟踪)
journalctl -u option-discover.service -f
journalctl -u option-track.service -f
# 只看今天的
journalctl -u option-discover.service --since today
# 手动触发一次,不等timer(调试用)
sudo systemctl start option-discover.service
sudo systemctl start option-track.service
# 导出衰减曲线图 + CSV(攒几天数据后再跑才有意义)
.venv/bin/python3 -m src.option_monitor.export_chart
.venv/bin/python3 -m src.option_monitor.export_chart --symbol USAR
# 排查"某个合约收益率算得不对"
.venv/bin/python3 -m src.option_monitor.debug_chain --symbol CRML --min-dte 7
7. 收不到通知时的排查顺序
- 确认timer真的触发了:
journalctl -u option-discover.service --since today——如果连"扫描XXX的期权链"这行都没有,说明timer没跑起来(检查systemctl list-timers、树莓派有没有在那个时间点休眠/断网) - 看日志里的过滤原因:现在每个被拦下的合约都会打印具体原因 (年化多少、Delta多少、OI多少),一眼能看出是门槛太严还是真的没机会
- 门槛太严就调配置:
min_abs_delta/max_abs_delta放宽到 0.20-0.40,或者min_annual_yield降一点,改完不用重启,下次timer 触发自动生效 - 怀疑数据本身有问题:用
debug_chain.py直接看原始报价,绕开 所有业务逻辑
运维排查:48分钟的运行时长,查出来是部署没同步
系统跑了一段时间后,发现track.py某次运行花了48分钟——远超预期的几分钟。 导出journalctl日志排查,发现两个问题,都不是新bug,是部署没跟上代码:
日志里全是逐条合约的记录,比如USAR 2027-03-19 13.0 call: bid=4.65 年化=23.14%,一行一个——这是重构之前的日志格式。新版track.py早就 改成"每个symbol只拉一次完整期权链、缓存成字典",不会再逐条打印。日志 格式对不上,说明树莓派上跑的根本不是最新代码。
追踪队列里堆了873个合约——因为discover.py会把所有新合约(不管通不 通知)都塞进观察队列,watchlist里标的一多、每个到期日strike一多,几天 就能堆到几百个。旧版track.py对每个队列里的合约单独调get_single_quote(), 而这个方法内部每次都要重新拉一遍整条期权链再从里面筛——873个合约 等于873次"拉全链",这才是48分钟的真正来源。
顺带还挖出另外两个连带问题:
- 两个systemd timer撞在同一分钟:早期
track.timer和discover.timer都设在16:00整点,两边同时抢SQLite写锁,日志里能看到sqlite3.OperationalError: database is locked。后来把track.timer错开到17点起步,两边不会再抢同一把锁 - rsync同步撞上定时任务触发:日志里有一批
FileNotFoundError: config/watchlist.yaml,连续失败了7次——大概率是 代码同步到一半,目录短暂不完整,刚好被那一刻触发的timer撞上。后来养成 习惯:同步代码前先systemctl stop两个timer,同步完再start
结论:这次排查最后没有改一行代码——把最新版track.py/discover.py/ track.timer重新完整同步一遍,问题就消失了。教训是改完代码,一定要 确认真的同步上去了,尤其是多个文件互相依赖的改动,漏同步一个就可能 表现成完全不相关的"性能问题",容易误判方向。
磁盘和运行频率:两个"要不要"的权衡
journald日志要不要设上限:systemd默认不限制日志大小,长期跑下去会 一直涨。加两行配置就够:
# /etc/systemd/journald.conf
[Journal]
SystemMaxUse=500M
MaxRetentionSec=30day
sudo systemctl restart systemd-journald生效,journalctl --disk-usage 随时能看当前占用。
track.py的轮询间隔要不要缩短:问过这个问题,结论是不太需要。这套 系统筛的是Delta 0.15-0.30这个区间的"收租"机会,好不好取决于IV、剩余 天数、行权价离现价的距离——这些是小时级甚至天级的变化节奏,不是分钟级。 缩短间隔的代价是实打实的:yfinance限流风险更高、单次运行时长和触发间隔 的安全边际变小(容易撞上"上一次还没跑完,下一次又触发了")。
不过后来还是从1小时调成了30分钟——不是推翻了上面的结论,是运行时长 问题排查清楚、确认单次运行只要几分钟之后,30分钟对1小时的安全边际来说 风险可控,属于"能承受就试试"的调整,跟"必须缩短"是两回事。systemd timer改起来很简单:
# OnCalendar=Mon..Fri 17..22:00 旧的,每小时
OnCalendar=Mon..Fri 17..22:00,30 # 新的,每30分钟(17:00,17:30,18:00...)
一次绕路:想做历史回测,但数据源比想象中难找
最初的想法是:“能不能拿这个刚挂牌的远期合约,回测一下历史上类似情况的 表现”。这条路线走下来,踩了几个不算浪费但值得记录的弯路:
yfinance没有历史期权数据——它只给当前快照,查不了"某个历史日期这个 合约报价多少",这是接口能力的硬限制,不是配置问题。
IBKR的historical data API明确排除已到期期权——官方文档写得很清楚: “Expired options"不可用,只能查当前仍在交易的合约。也就是说就算重新 搭建IBKR Gateway常驻(之前为了省运维成本放弃过这条路),也拿不到真正 需要的"很多次历史上的首日样本”,Gateway那部分投入白费。
真正走通的是MarketData.app——免费层100次/天、历史数据回溯1年, 实测确认它确实存着已到期合约的数据,intrinsicValue/extrinsicValue 这两个字段官方文档没有被列进"历史查询返回null"的名单(IV/Delta/Gamma/ Theta/Vega这几个才是,所有历史查询固定返回null,不分付费级别),刚好是 做衰减分析最需要的指标,不用自己拆分。
历史研究脚本:找到真实的历史新开合约
设计思路:
- 按固定间隔往回扫
expirations接口,找出过去到期日列表里"什么时候 开始出现",这就是"新增到期日"的粗略锚点 - 没有历史Delta,改用虚值幅度反推行权价:算出目标行权价,构造OCC 代码直接去查
quotes接口验证是否真实存在,查不到就试相邻的,自己验证 自己,不依赖假设 firstTraded字段给出权威首日:一旦找到有效合约,这个字段直接 告诉你它真正开始交易的日期,不用再靠"扫描时第一次出现"这种粗略估计
免费层100次/天,脚本设计成可以分多天跑、自动断点续跑:进度写进一个 JSON文件,每次运行到达credit预算就停,下次运行接着上次的位置继续, 不会重复消耗额度。
跑完之后用一个独立的报告脚本统计"持有N天后,浮盈概率多大、平均浮盈 多少"——这两个指标要一起看,不能只看单一列:概率100%但平均只赚1%, 跟概率60%但平均赚15%,哪个划算取决于自己的风险偏好,脚本不替你下结论, 只给数据。
一个语义bug:置顶显示的"N天前新开",其实不准
系统上线后加过一个功能——把"N天内新开的合约"单独拎到通知最前面(逻辑 是新合约流动性还没建立,做市商报价可能带溢价,值得优先看)。用了一段 时间后发现问题:显示的"N天前新开",其实是本系统第一次扫描到这个合约 的日期,不是交易所真实的挂牌日。
这个区别平时看不出来,但这套系统上线以来因为修bug清过好几次库—— 每清一次,“首次发现日期"就归零重来。也就是说,显示的"4天前新开”,很 可能只是"4天前最后一次清库重跑",跟合约真实挂牌时间毫无关系。
修复思路:不想为了这个每次通知都去调用MarketData(会员额度有限, 且这块设计成免费实时监控,不想引入付费依赖),改成按需查询+永久 缓存——一个合约的真实首次交易日不会变,只对"当天要进置顶板块的少数 几个合约"查一次MarketData的firstTraded字段,查过之后存进一张缓存表, 以后再也不用重复花额度。
效果符合预期:如果核实出来的真实首次交易日比本系统记录的更早、已经 超出了置顶的天数窗口,这个合约会自动从置顶板块移出,回到普通分组 ——不会因为本系统数据不准而错误置顶。Telegram消息里核实过的标"已核实", 没核实的标"系统记录",一眼能分清哪些数字可信。
顺带解决了个使用体验问题:.env文件之前只有systemd的服务能自动读取 (靠unit文件里的EnvironmentFile=),手动跑的脚本(历史回测、这次的 核实查询)每次都要自己export。写了个不依赖python-dotenv的轻量加载器 (几行代码自己解析KEY=VALUE),手动脚本跑之前自动读一遍.env, 不覆盖已经export过的值。
核实机制上线后又踩了一次:查询时机不对
加了MarketData核实之后,发现有些合约明明应该被核实成功,却一直显示 “系统记录”(没核实)。排查下来是查询本身的问题:核实逻辑传了 date.today()给MarketData的报价接口,这会让查询变成一次"历史查询"—— 而MarketData的历史EOD数据要等收盘结算后才会生成,如果脚本在收盘前 跑,查"今天"这个历史日期就是空的,不是合约没有数据,是查询时机选错了。
修复很直接:不传日期,查"当前实时报价",不依赖当天历史快照是否已经 生成。顺带给核实逻辑加了详细日志(命中缓存/没配key/查不到合约/查到了 没有firstTraded字段/核实成功——每一种情况都单独打一行),以后再遇到 “该核实的没核实”,日志能直接告诉你卡在哪一步,不用靠猜。
历史统计的方法论坑:day1基准要求太死板
historical_backtest_report.py跑出来的第一批统计,41个episode最后 进统计的只有7个,day45更是只剩3个——排查发现不是数据丢了,是统计逻辑 本身要求每个episode必须有day1这个采样点、且外在价值不为空,才会 被纳入计算。问题是:挂牌第一天恰恰是最容易没有真实报价的一天(前面 撞过好几次"新合约刚挂牌没人做市"),这个特征在day1体现得最明显,导致 大量episode在第一道关卡就被无辜刷掉。
修复:把"必须有day1"放宽成"用每个episode实际拥有的最早采样点当基准", 不再死抠正好是day1。同时给"过滤完一个episode都不剩"这种情况加了清楚 的提示——原来这种情况会静默输出一个空表格,看起来像脚本卡死,其实只是 数据不够,两者不该长得一样。
这次的教训是:样本量小的时候,先怀疑统计口径本身有没有问题,不要 急着下结论——7个样本、86%的"盈利概率",听起来像个信号,实际置信区间 宽到没有意义,方法论上多抢救几个episode,比纠结这个数字本身更有价值。
从"历史平均"到"这个仓位现在怎么样":单持仓对比工具
积累了一些历史数据之后,想到一个应用方向:拿手上正持有的一个具体仓位 (免费的yfinance追踪数据),跟历史基准曲线(付费MarketData数据算出来的) 比一比——“我这个仓位衰减得算快算慢”。
设计上想清楚一件事:两边数据库的指标不直接兼容。免费的contract_tracking 表只存了bid/mid/年化,没单独存外在价值;付费的study_sample表存的是 extrinsic_value。对比之前,免费那边的外在价值得现查现算(用 intrinsic_value()函数从bid里减掉内在价值)。
想过三种思路:
- 单个持仓 vs 历史基准(选了这个)——直接服务"这个仓位现在该不该 平仓",工程量最小,不依赖历史样本量已经很大
- 全watchlist批量对比看板——找出衰减得比历史正常水平快的仓位, 批量提示。工程量更大,且现阶段历史基准本身样本量还小,批量对比出来 的"异常"可能只是噪音
- 接入实时通知,让每条推送自带历史参照——工程量最大,而且高频 曝光一个置信度还不够的基准值,弊大于利
做出来的效果:一张表,每个"距首日天数"对应实时外在价值、实时浮盈%、 历史同期中位数%、样本数,外加一个简单判定(快于/慢于/接近历史,±5个 百分点分档,这个阈值没什么理论依据,纯粹是拍的)。可以选历史基准用 全部标的汇总(样本多)还是只用同一个标的(更贴合但样本少)。
一个"重点关注"清单:跟通用筛选并行的第二套逻辑
系统原来的通用筛选只回答一个问题:“现在有什么新机会”——要过年化、Delta、 OI这几道门槛才会被通知。但持有正股、专门想盯着"高于成本价多少的covered call"这种场景,跟"发现新机会"不是一回事:即使这个行权价的年化不够高、 Delta不在常规区间,只要落在自己设定的行权价范围内,就想看到它,作为 持续参考,不是等它"达标"才通知。
加了一个独立的priority_watch配置项,跟通用筛选完全解耦:
priority_watch:
- symbol: USAR
option_type: call
min_strike: 30 # 只关心行权价30以上的
cost_basis: 20 # 只用于消息里显示参考,不影响筛选
两个设计取舍:
- 不受年化/Delta/OI这几个通用门槛限制,只按行权价范围筛(仍然套 最小到期天数,避免到期太短的噪音合约)——这批合约不是"机会",是 “参考”,两种性质不该用同一套筛选逻辑
- 每次运行都重新算,不做每日去重——通用筛选的"发现新机会"逻辑需要 去重(同一个机会不用天天提醒),但"参考清单"应该每次都展示最新数字, 这是两种不同的通知语义,不能共用一套去重机制
展示上放在整条消息最开头,独立于其他板块,按年化降序排列,附带标的 现价和成本价方便一眼判断行权价离现价、离成本有多远。
历史研究要不要定时跑:只收集已到期的,但可以持续增长
攒了几周历史数据后回头看historical_backtest.py,发现一个设计缺口: 扫描过一次的标的,代码会永久跳过,不会再发现新出现的到期日。 USAR/CRML/NB这些标的每个月都会自然滚动新增到期日周期,这些新到期日 本来会在未来变成新的可分析样本,但原来的逻辑下永远进不了研究库。
顺带确认了一个容易搞混的边界:这套历史研究工具,只收集"已经到期"的 合约数据,正在存续期、还没到期的完全不采样。不是漏掉了,是故意的—— 要统计"持有N天后表现如何",前提是这个episode得已经走完全程,拿一个 还有200天才到期、现在只过了10天的合约进来统计,会让不同天数点的样本 结构不对称。正在存续的合约,其实交给了另一条免费的轨道(discover.py+ track.py+yfinance)在管,两条轨道分工不同,不重复也不遗漏。
修复思路:已经做过完整历史扫描的标的,改成每次只花1次请求查一下 “现在的到期日列表”,跟已知的做diff,发现新的就记下来——不重新扫整个 历史区间,成本从"一次几十个请求"降到"增量1个请求"。这样就可以配一个 新的systemd timer,每周跑一次,让研究库随时间自然增长:
# historical-backtest.timer
[Timer]
OnCalendar=Sat 10:00 # 周六上午,不跟交易时段的discover/track任务抢资源
时间戳显示:从UTC改成美东时间
Telegram消息里的时间戳原来是UTC,看着别扭,改成美东时间(ET)——用 Python自带的zoneinfo标准库(3.9+自带,不用装新依赖),会自动处理 夏令时/冬令时切换,比之前给systemd timer手动算西班牙/美东固定时差 更省心:
from zoneinfo import ZoneInfo
US_EASTERN = ZoneInfo("America/New_York")
def format_et_timestamp(dt: datetime) -> str:
return dt.astimezone(US_EASTERN).strftime("%Y-%m-%d %H:%M ET")
配置参数速查
全部在 config/watchlist.yaml,改完不用重启(oneshot服务下次触发自动 读最新配置):
| 参数 | 默认值 | 作用 | 什么时候调 |
|---|---|---|---|
symbols | — | 监控哪些标的 | 加减标的 |
track_days | 7 | 新合约追踪几天 | 想看更长的衰减曲线就调大 |
notify_types | put, call | 通知put还是call | 只做cash-secured put就删掉call |
min_annual_yield | 0.20 | 年化门槛 | 通知太多就往上调 |
min_days_to_expiry | 4 | 到期天数下限 | 防止年化被短到期放大,实测调到4天够用 |
min_abs_delta | 0.15 | Delta下限 | 调小=接受更虚值(更安全、权利金更少) |
max_abs_delta | 0.30 | Delta上限 | 调大=更激进,接近0.5就是赌方向 |
min_open_interest | 20 | OI下限 | 冷门标的可以调小,但报价可信度会降 |
max_per_symbol | 7 | 每标的通知上限 | 标的少可以调大或设null |
notify_mode | summary | 汇总还是逐条 | 标的少可以用individual |
new_contract_highlight_days | 7 | N天内的合约置顶显示 | 设null取消置顶板块 |
new_contract_min_open_interest | 4 | 置顶合约单独的OI下限 | 新合约OI天然低,实测调到4 |
verify_first_traded_via_marketdata | true | 花MarketData credit核实真实首次交易日 | 没配key时自动跳过,不报错 |
priority_watch | 空列表 | 独立于通用筛选的重点关注清单 | 持有正股、想盯着特定行权价范围时配置 |
设成 null 表示不启用该过滤。上面这些是实际线上在用的值,不是脚手架 默认值——跑了一段时间之后,min_days_to_expiry从7天松到4天、 min_open_interest从50松到20,都是看了几周实际通知数据之后手动调的, 没有什么理论依据,纯粹是"太严了看不到东西,往松了调一点"这种迭代。
小结
这套系统目前包含:期权年化收益率计算(区分内在/外在价值;bid/ask同时为0 时归一化成缺失值)、新挂牌合约自动发现(每日diff)、交易时段分阶段扫描 通知(不限于新合约,按天去重)、Delta/OI/每标的数量三道通知过滤、新开 合约置顶展示(MarketData按需核实真实首次交易日,永久缓存不重复耗额度)、 独立于通用筛选的重点关注清单(按行权价范围持续参考,不做去重)、 IV/Volume/OI/Delta指标采集、按标的分组的通知排版(美东时间显示)、 报价追踪与衰减曲线导出、一套基于MarketData.app真实历史数据的合约衰减 研究工具(增量扫描+断点续跑,每周定时跑,不用IBKR/付费历史API也能拿到 已到期合约数据)、单持仓vs历史基准的对比工具、Telegram通知(汇总+错误 告警+崩溃告警)、systemd定时任务(discover每天一次、track每30分钟一次、 historical-backtest每周一次)、独立的诊断脚本、轻量.env加载器。数据源 当前用yfinance,接口做了抽象,未来要换Tradier或Alpaca只需要新写一个 Provider实现类。
回头看,踩的坑大致分四类:
计算逻辑的坑——深度ITM合约算出8796%年化(没区分内在/外在价值)、 到期天数太短导致年化被放大91倍、历史统计死抠day1基准浪费掉大量本来 能用的episode。这类问题的共同点是"公式或统计口径在边界条件下失效", 写的时候觉得没问题,真实数据一撞就露馅。
产品设计的坑——只通知新合约导致交易时段"什么都没发生"、241条通知 等于没通知、“N天前新开"其实是本系统清库后重新计时、核实查询传错日期 导致该核实的没核实。这类问题更隐蔽,因为代码完全按设计工作,是设计 本身没考虑真实使用场景、或者字段/查询语义被想当然了——一个字段叫 “首次发现”,容易被默认成"真实挂牌日”;一次"查当前"的需求,随手传了 date.today()就变成了"查历史",两者在API语义上是不同的请求。
环境相关的坑——盘前跑脚本查到全是0、远期LEAPS大量行权价没有真实 报价。这类只能靠实际跑起来撞,没有别的办法。
部署/运维的坑——48分钟的运行时长,最后查出来是代码没同步完整, 不是性能问题。这类坑最容易误判方向,花时间在错误的地方排查—— 教训是先确认"跑的到底是不是最新代码",比直接下手优化更省时间。
后续还想加的:中文字体支持(现在图表标签是英文,树莓派默认字体没有中文 字形)、错误通知的抑制逻辑(避免同一个错误连续报警刷屏)、历史波动率(HV) vs IV对比(现在有了MarketData的历史数据积累渠道,这个二期工程比之前 更有条件做了)、把重点关注清单也接入单持仓对比工具(现在两者是分开的)。