IBKR Paper Trade 部署 — 月度自动再平衡
从回测脚本到 paper trade 系统的工程化原则、月度再平衡的幂等设计、IB Gateway 长跑的可用性 trade-off、生产化监控的指标体系
日期: 2026-06-14 方向: Phase 2 / 部署 阶段: Phase 2: 策略实战 + AI 信号 标签: #PaperTrade #Deployment #IBKR #ib_insync #Scheduler #Monitoring #Production
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 从回测脚本到 paper trade 系统的工程化原则、月度再平衡的幂等设计、IB Gateway 长跑的可用性 trade-off、生产化监控的指标体系 |
| 实操 | 把 Day 28 双因子组合改造成「每月 1 号自动跑」的 paper trade 系统、写 rebalance.py / data_loader.py / signal_engine.py / executor.py 四个模块、Windows Task Scheduler 配置、首次 dry-run + 实跑 |
| 产出 | TR-DAY36 笔记 + 4 个模块化脚本 + 部署 checklist + 首次实跑 trade log + 监控指标基线 |
一、为什么 Day 36 是「部署日」而不是「再多写个策略」
Phase 2 进行到第 6 天,前面 5 天(Day 31-35)覆盖了组合层面的内容:风险预算、相关性约束、分仓权重、再平衡频率、税务效率。这些都是方法论层面。
到 Day 36,必须把 Day 28 跑通的双因子组合(动量 z-score + 质量 z-score → SP100 top decile)从「回测脚本」升级成「会自己跑的系统」。这一步不做,Phase 2 后面的 AI 信号、事件驱动都是纸上策略——因为你根本没有一个能持续吃信号的执行底座。
1.1 「回测脚本能跑」≠「paper trade 能跑」
| 维度 | 回测脚本 (Day 28) | Paper Trade 系统 (Day 36) |
|---|---|---|
| 触发方式 | 手动 python backtest.py | Task Scheduler 月初自动 |
| 数据来源 | 静态历史 parquet | 月初实时拉取 + 缓存 |
| 状态 | 无状态(每次从零) | 有状态(与当前持仓 diff) |
| 失败模式 | 报错就停 | 必须 retry / fallback / 告警 |
| 重复执行 | 重新跑一遍而已 | 必须幂等,否则重复下单 |
| 时间窗口 | 任意 | 受市场开盘 / earnings / 节假日约束 |
| 监控 | 看终端输出 | 日志 + 告警 + 成交记录 |
最容易踩的坑:以为「把 backtest.py 加个 if datetime.day == 1 就行」。这种思路会在第 2 个月炸——比如月初是周末、API 连不上、IB Gateway 凌晨重启没回来,脚本静默失败,你一周后才发现这个月没再平衡。
1.2 这是个 MVP → Beta 的工程化跳跃
从「策略能赚 100bps」到「系统能稳定跑 12 个月不出事」,工程量是 5x,策略本身只占 20%。
这条经验在金融 PM 的语境下并不陌生——核心系统上线前的 SIT/UAT/灰度/熔断/对账,全部是「策略本身」之外的成本。今天我们就要把这套思路套用到个人量化系统上。
二、系统架构设计(四层模型)
2.1 整体架构图
┌─────────────────────────────────────────────────────────────────┐
│ Windows Task Scheduler │
│ (每月 1 号 09:30 ET / 21:30 BJT 触发) │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ rebalance.py │ ← 主入口(幂等)
│ ┌────────────────────────┐ │
│ │ 1. 检查市场是否开盘 │ │
│ │ 2. 检查本月是否已跑 │ │
│ │ 3. 编排四层调用 │ │
│ │ 4. 写 run_log │ │
│ └────────────────────────┘ │
└──────┬───────────┬───────────┘
│ │
┌────────────┘ └────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ data_loader.py │ │ signal_engine.py │
│ ─────────────── │ │ ──────────────────── │
│ • SP100 list │ ──parquet──► │ • composite z-score │
│ • financials │ │ • top decile filter │
│ • prices │ │ • universe sanity │
│ • cache layer │ │ • output target wt │
└──────────────────┘ └──────────┬───────────┘
│ target_weights.json
▼
┌──────────────────────┐
│ executor.py │
│ ──────────────────── │
│ • get current pos │
│ • compute diff │
│ • generate orders │
│ • submit to IBKR │
│ • monitor fills │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ IBKR Paper (4002) │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ trade_log.sqlite │
│ ──────────────────── │
│ • runs / orders / │
│ fills / positions │
└──────────────────────┘
2.2 四层职责清单
数据层 (data_loader.py)
- 月初 1 号增量拉取:SP100 成分股、最新 financials、过去 252 交易日价格
- 缓存:
./cache/sp100_{yyyymm}.parquet、./cache/financials_{yyyymm}.parquet - 失败处理:财报数据缺失允许,价格数据缺失必须报错
信号层 (signal_engine.py)
- 输入:data_loader 产出的 DataFrame
- 计算:动量 12-1 月 return、质量 ROE+ROA+毛利率,cross-sectional z-score
- 输出:top 10 标的的目标权重(等权)写到
./out/target_weights_{yyyymm}.json
执行层 (executor.py)
- 连接 IB Gateway Paper(端口 4002)
- 拉当前持仓 → 与 target 做 diff → 生成 buy/sell 订单
- 下 limit order at mid,等待 30 分钟 → 未成交则取消并降级到 market(或留到下个月)
- 记录每笔订单状态到 SQLite
监控层 (logging + alerts)
- 结构化日志:每次 run 一份
./logs/rebalance_{yyyymmdd_hhmm}.log - 异常告警:连接失败 / 数据异常 / 订单 reject → Bark / Telegram / Email(选一个)
- 关键指标:滑点 bps、turnover、未成交率
三、关键代码(精简但生产可用)
3.1 rebalance.py — 主入口(幂等核心)
"""
rebalance.py — Monthly rebalance entrypoint.
Idempotency contract:
- If today is not the 1st trading day of the month → exit 0 (no-op)
- If a successful run for this month already exists → exit 0 (no-op)
- If a failed run exists → retry allowed (but with manual override flag)
"""
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import pandas_market_calendars as mcal
from data_loader import load_universe_and_data
from signal_engine import compute_target_weights
from executor import execute_rebalance
from monitor import alert, init_logging
DB_PATH = Path("./trade_log.sqlite")
NYSE = mcal.get_calendar("NYSE")
def is_first_trading_day(date: datetime) -> bool:
"""First trading day of the current month."""
schedule = NYSE.schedule(
start_date=date.replace(day=1),
end_date=date,
)
if schedule.empty:
return False
return schedule.index[0].date() == date.date()
def already_ran_this_month(date: datetime) -> bool:
conn = sqlite3.connect(DB_PATH)
yyyymm = date.strftime("%Y%m")
row = conn.execute(
"SELECT status FROM runs WHERE yyyymm = ? AND status = 'SUCCESS'",
(yyyymm,),
).fetchone()
conn.close()
return row is not None
def main(dry_run: bool = False, force: bool = False) -> int:
logger = init_logging()
today = datetime.now()
# Idempotency gates
if not is_first_trading_day(today) and not force:
logger.info("Not the first trading day; skip.")
return 0
if already_ran_this_month(today) and not force:
logger.info("Already rebalanced this month; skip.")
return 0
try:
logger.info("Step 1/3: load data")
universe, prices, fundamentals = load_universe_and_data(today)
logger.info("Step 2/3: compute signals")
target = compute_target_weights(universe, prices, fundamentals)
logger.info(f"Target portfolio: {len(target)} names, sum_w={sum(target.values()):.4f}")
logger.info("Step 3/3: execute")
result = execute_rebalance(target, dry_run=dry_run)
_record_run(today, "SUCCESS", result)
logger.info("Rebalance completed.")
return 0
except Exception as e:
logger.exception("Rebalance failed")
_record_run(today, "FAILED", {"error": str(e)})
alert(f"[CRITICAL] Rebalance failed: {e}")
return 1
def _record_run(date, status, payload):
conn = sqlite3.connect(DB_PATH)
conn.execute(
"INSERT INTO runs(yyyymm, run_ts, status, payload) VALUES (?, ?, ?, ?)",
(date.strftime("%Y%m"), date.isoformat(), status, str(payload)),
)
conn.commit()
conn.close()
if __name__ == "__main__":
dry = "--dry-run" in sys.argv
force = "--force" in sys.argv
sys.exit(main(dry_run=dry, force=force))
幂等设计的三道闸:
is_first_trading_day— 不是月初不跑already_ran_this_month— 本月成功过不跑--force— 显式手动覆盖(用于 retry 或测试)
这三道闸保证 Task Scheduler 即使因为机器重启重复触发、即使你手动 python rebalance.py,都不会重复下单。
3.2 data_loader.py — 缓存 + 增量
"""
data_loader.py — SP100 + financials + prices with caching.
"""
from pathlib import Path
import pandas as pd
import yfinance as yf
CACHE = Path("./cache")
CACHE.mkdir(exist_ok=True)
def load_universe_and_data(date):
yyyymm = date.strftime("%Y%m")
sp100_file = CACHE / f"sp100_{yyyymm}.parquet"
prices_file = CACHE / f"prices_{yyyymm}.parquet"
fund_file = CACHE / f"fundamentals_{yyyymm}.parquet"
# 1. SP100 list — 来源可以是 Wikipedia scraper / IBKR Universe / 你自己维护的 csv
if sp100_file.exists():
universe = pd.read_parquet(sp100_file)["ticker"].tolist()
else:
universe = _scrape_sp100()
pd.DataFrame({"ticker": universe}).to_parquet(sp100_file)
# 2. Prices — 252 trading days for 12-1 momentum
if prices_file.exists():
prices = pd.read_parquet(prices_file)
else:
prices = yf.download(universe, period="400d", auto_adjust=True)["Close"]
prices.to_parquet(prices_file)
# 3. Fundamentals
if fund_file.exists():
fundamentals = pd.read_parquet(fund_file)
else:
fundamentals = _load_fundamentals(universe)
fundamentals.to_parquet(fund_file)
# Sanity checks
assert len(universe) >= 90, f"Universe too small: {len(universe)}"
assert prices.shape[0] >= 240, f"Not enough price history: {prices.shape}"
return universe, prices, fundamentals
def _scrape_sp100():
"""Wikipedia 抓 SP100 成分(生产环境最好换成付费源)"""
url = "https://en.wikipedia.org/wiki/S%26P_100"
tables = pd.read_html(url)
for t in tables:
if "Symbol" in t.columns:
return t["Symbol"].tolist()
raise RuntimeError("Cannot find SP100 table")
def _load_fundamentals(tickers):
"""yfinance 拿基本面数据(PoC 用,生产换 Bloomberg / Compustat)"""
rows = []
for t in tickers:
try:
info = yf.Ticker(t).info
rows.append({
"ticker": t,
"roe": info.get("returnOnEquity"),
"roa": info.get("returnOnAssets"),
"gross_margin": info.get("grossMargins"),
})
except Exception:
continue
return pd.DataFrame(rows).set_index("ticker")
关键设计点:
- 缓存 key 用
yyyymm,每月自然分文件,调试时可以直接看历史 - yfinance 是 PoC 数据源,生产化要换稳定源(Polygon / IB historical)
- assert 兜底——universe 太小或价格历史不够就直接 fail fast
3.3 signal_engine.py — z-score 合成
"""
signal_engine.py — momentum 12-1 + quality composite, top decile.
"""
import numpy as np
import pandas as pd
TOP_N = 10 # SP100 top decile = 10 names
def compute_target_weights(universe, prices, fundamentals):
# 1. Momentum 12-1 (skip last month to avoid reversal)
px = prices[universe].dropna(axis=1, how="all")
ret_12_1 = (px.iloc[-21] / px.iloc[-252] - 1) # ~12mo skipping last 21d
# 2. Quality = mean of standardized ROE, ROA, gross_margin
q = fundamentals.reindex(px.columns)
q_z = q[["roe", "roa", "gross_margin"]].apply(_zscore)
quality = q_z.mean(axis=1)
# 3. Composite
mom_z = _zscore(ret_12_1)
composite = 0.5 * mom_z + 0.5 * quality
composite = composite.dropna()
# 4. Top decile, equal weight
top = composite.nlargest(TOP_N).index.tolist()
weights = {t: 1.0 / TOP_N for t in top}
return weights
def _zscore(s: pd.Series) -> pd.Series:
return (s - s.mean()) / s.std(ddof=0)
策略本身只有 30 行——这正是工程化的写照。回测时复杂的因子构建到了生产,沉淀下来就是一个干净的函数。
3.4 executor.py — IBKR 通信 + diff
"""
executor.py — IBKR Paper executor with mid-price limit orders.
"""
import time
from datetime import datetime
from ib_insync import IB, Stock, LimitOrder, MarketOrder
IB_HOST = "127.0.0.1"
IB_PORT = 4002 # IB Gateway Paper
CLIENT_ID = 1
# Hard safety — 任何连接到非 paper 端口的尝试立即退出
assert IB_PORT == 4002, "WRONG PORT — PAPER ONLY"
def execute_rebalance(target_weights, dry_run=False):
ib = IB()
_connect_with_retry(ib)
try:
account = ib.managedAccounts()[0]
nav = float([v.value for v in ib.accountSummary(account) if v.tag == "NetLiquidation"][0])
current = _get_current_positions(ib)
target_usd = {t: w * nav for t, w in target_weights.items()}
orders_plan = _compute_orders(ib, current, target_usd)
print(f"Plan: {len(orders_plan)} orders, dry_run={dry_run}")
for o in orders_plan:
print(f" {o['action']:4s} {o['symbol']:6s} {o['qty']:>5} @ mid {o['limit']:.2f}")
if dry_run:
return {"orders": orders_plan, "submitted": 0}
submitted = _submit(ib, orders_plan)
fills = _monitor_fills(ib, submitted, timeout_sec=1800)
return {"orders": orders_plan, "submitted": len(submitted), "fills": fills}
finally:
ib.disconnect()
def _connect_with_retry(ib, attempts=3):
for i in range(attempts):
try:
ib.connect(IB_HOST, IB_PORT, clientId=CLIENT_ID, timeout=15)
return
except Exception as e:
print(f"Connect attempt {i+1} failed: {e}; retrying in 30s")
time.sleep(30)
raise RuntimeError("Cannot connect to IB Gateway after 3 attempts")
def _get_current_positions(ib):
return {p.contract.symbol: p.position for p in ib.positions() if p.position != 0}
def _compute_orders(ib, current, target_usd):
"""生成 diff orders。卖空全平,再开 long。"""
orders = []
# SELL: 不在 target 里的当前持仓
for sym, qty in current.items():
if sym not in target_usd and qty > 0:
mid = _get_mid(ib, sym)
orders.append({"action": "SELL", "symbol": sym, "qty": int(qty), "limit": mid})
# BUY: target 里需要买入的(简化:忽略已持有的差额,第二个月再 rebalance 量)
for sym, usd in target_usd.items():
if sym in current:
continue
mid = _get_mid(ib, sym)
qty = int(usd / mid)
if qty > 0:
orders.append({"action": "BUY", "symbol": sym, "qty": qty, "limit": mid})
return orders
def _get_mid(ib, symbol):
contract = Stock(symbol, "SMART", "USD")
ib.qualifyContracts(contract)
ticker = ib.reqMktData(contract, "", snapshot=True)
ib.sleep(2)
if ticker.bid > 0 and ticker.ask > 0:
return round((ticker.bid + ticker.ask) / 2, 2)
return ticker.last or ticker.close
def _submit(ib, orders_plan):
trades = []
for o in orders_plan:
contract = Stock(o["symbol"], "SMART", "USD")
ib.qualifyContracts(contract)
order = LimitOrder(o["action"], o["qty"], o["limit"], tif="DAY")
trade = ib.placeOrder(contract, order)
trades.append(trade)
ib.sleep(0.2) # 防止 pace violation
return trades
def _monitor_fills(ib, trades, timeout_sec=1800):
deadline = time.time() + timeout_sec
while time.time() < deadline:
all_done = all(t.isDone() for t in trades)
if all_done:
break
ib.sleep(10)
return [
{
"symbol": t.contract.symbol,
"filled": t.filled(),
"remaining": t.remaining(),
"avg_price": t.orderStatus.avgFillPrice,
"status": t.orderStatus.status,
}
for t in trades
]
几个生产关键点:
assert IB_PORT == 4002:第一次见这个 pattern 是在 Day 1 笔记,今天它救命——脚本被改成 4001 立即崩_connect_with_retry:IB Gateway 偶尔抽风,3 次 retry + 30s 间隔够大多数情况ib.sleep(0.2):IBKR 有 pacing limit(50 msg/s),密集下单要主动 throttle- limit at mid:教科书做法。极度 illiquid 的 SP100 标的(其实没有)才需要降级 market
四、调度方案选型
4.1 方案 A vs 方案 B
| 维度 | A: Windows Task Scheduler | B: APScheduler 长跑 |
|---|---|---|
| 复杂度 | 低(系统自带) | 中(需要 supervisor) |
| 可靠性 | 高(OS 级) | 中(进程崩了就停) |
| IB Gateway 重连 | 不需要(每月只跑 30 分钟) | 必须(IBG 每天 04:00 ET 重启) |
| 故障恢复 | 重启电脑也没事 | 需要 systemd / nssm 守护 |
| 监控 | Task Scheduler 历史 | 自己写 health endpoint |
| 适合场景 | 月度 / 周度低频 | 日内高频 |
今天的选择:A。原因:
- 月度再平衡,跑完就退,根本不需要长跑
- Phase 2 的目标是「跑通流程」,引入 APScheduler 多一层复杂度,故障面扩大
- Phase 3 升级到日内信号 / Wheel 自动管理时再切 B
4.2 Task Scheduler 配置
触发器:
- 类型:按月
- 月份:每月
- 日期:1 号
- 时间:21:30 BJT (= 09:30 ET 开盘时刻;夏令时影响 ±1 小时,宁可早不可晚)
操作:
- 程序:C:\Users\xc\miniconda3\envs\trading\python.exe
- 参数:E:\code\momofinance\momoweb3\trading\rebalance.py
- 起始位置:E:\code\momofinance\momoweb3\trading\
设置:
- 如错过运行,尽快启动 ✓
- 任务失败重新启动间隔:30 分钟,最多 3 次 ✓
- 任务运行超过 2 小时强制停止 ✓
- 不允许并行实例 ✓(这一条至关重要,否则机器睡眠唤醒会重复触发)
夏令时陷阱:美东 03 月第二个周日 → 11 月第一个周日是 EDT(UTC-4),其余 EST(UTC-5)。BJT 与美东永远差 12 或 13 小时。对策:在 rebalance.py 入口加一个「现在是不是 ET 时间 09:30-10:00 窗口」的硬性检查,触发时间在窗外直接 sleep 或 exit。
五、错误处理矩阵
| 错误 | 频率 | 处理 | 告警 |
|---|---|---|---|
| IB Gateway 没启动 | 偶尔 | retry 3 次 → fail | P0 |
| TWS API 端口被占用 | 罕见 | 换 clientId 再试 | P1 |
| 市场数据 timeout(某标的) | 偶尔 | skip 该标的,记录在 run_log | P2 |
| 订单 reject (no permission) | 罕见(paper 一般不会) | log + skip | P1 |
| 订单 reject (insufficient funds) | 可能 | log + 跳过剩余 BUY | P0 |
| 部分成交(limit at mid 没全成) | 经常 | 30 分钟超时 → cancel;保留持仓不平 | P2 |
| 节假日跑了脚本 | 罕见 | NYSE calendar check 提前 exit | P3 |
| 缓存 parquet 损坏 | 罕见 | 删文件重新拉 | P2 |
| 网络抖动 | 经常 | retry | P3 |
P0 必须立刻告警(Telegram + Email),P1 Telegram,P2 日志即可,P3 仅 debug 日志。
5.1 一个最简单的告警实现
# monitor.py
import requests
BARK_KEY = "your_bark_key"
def alert(msg: str, level: str = "P1"):
if level in ("P0", "P1"):
try:
requests.get(f"https://api.day.app/{BARK_KEY}/{level}/{msg}", timeout=5)
except Exception:
pass # 告警挂了也不能让主流程挂
Bark 是 iOS 推送的最轻方案,免费、3 行代码。Telegram bot 也行,看个人偏好。
六、首次部署 Checklist
按顺序做,每一项打勾:
- (0) 模块化整理:把 Day 28 的代码拆成
data_loader.py / signal_engine.py / executor.py / rebalance.py - (1) IB Gateway:启动 → 登录 Paper 账户(不是 TWS,IBG 资源占用低)
- (2) API 设置:Configure → Settings → API
- 端口 4002 ✓
- 取消勾选 Read-Only(要下单了)
- Allow connections from localhost only ✓
- clientId 1(与代码一致)
- (3) 期权权限:虽然这个策略只交易股票,但确认 Level 2 已批(后续 wheel 策略要用)
- (4) 初始资金:Paper 账户 NAV 设为 $5,000(对应 Day 30 的资金分配)
- (5) SQLite 初始化:
CREATE TABLE runs (yyyymm TEXT, run_ts TEXT, status TEXT, payload TEXT); CREATE TABLE orders (run_ts TEXT, symbol TEXT, action TEXT, qty INTEGER, limit_px REAL); CREATE TABLE fills (order_id TEXT, symbol TEXT, qty INTEGER, price REAL, ts TEXT); - (6) dry-run 验证:
python rebalance.py --dry-run --force,看 plan 是否合理(10 个 BUY、合计 ~$5,000) - (7) 实跑:
python rebalance.py --force,监控 30 分钟看成交 - (8) trade log 写入验证:SQLite 里有 1 行 runs + 10 行 orders + N 行 fills
- (9) Task Scheduler 配置:按 4.2 节配置,下个月自动跑
- (10) 告警通道测试:手动调一次
alert("test", "P0"),确认手机能收到
重要:(6) 和 (7) 之间一定要看 dry-run 输出 30 秒以上,确认每一笔订单的方向、数量、价格都合理。任何「自动下单」系统都必须留这个 manual confirmation gate,至少在第一次跑的时候。
七、第一次实际跑(实测预演)
7.1 预期场景
- 时间:今天(Day 36)模拟「6 月 1 日」触发,加
--force - 数据:SP100 当前成分、Q1 2026 财报
- 信号:动量 12-1 + 质量复合 z-score,top 10
- 执行:约 10 笔 BUY,每笔 ~$500,limit at mid
7.2 预测 dry-run 输出
Plan: 10 orders, dry_run=True
BUY NVDA 5 @ mid 142.30
BUY MSFT 2 @ mid 415.80
BUY META 2 @ mid 588.10
BUY AAPL 3 @ mid 195.20
BUY GOOGL 4 @ mid 178.50
BUY ORCL 4 @ mid 142.80
BUY AVGO 3 @ mid 168.90
BUY COST 1 @ mid 905.00
BUY LLY 1 @ mid 845.20
BUY V 2 @ mid 290.50
(具体名单依实际信号而定,上面只是示意)
7.3 预测成交结果
- limit at mid 在 SP100 这种 mega cap 通常 80%+ 30 分钟内成交
- 剩下的可能是 wide spread 或 mid 价位流量稀薄 → 30 分钟超时 → cancel
- 平均滑点:5-15 bps(mid → 实际成交价的差异,远好于 market order 的 20-50 bps)
7.4 写入 trade_log 的 schema(沿用 Day 27)
runs:
yyyymm 202606
run_ts 2026-06-14T21:30:00
status SUCCESS
payload {...}
orders:
run_ts 2026-06-14T21:30:00
symbol NVDA
action BUY
qty 5
limit_px 142.30
fills:
order_id N12345
symbol NVDA
qty 5
price 142.34
ts 2026-06-14T21:33:15
八、十个最常见的部署坑(血泪 checklist)
| # | 坑 | 现象 | 防御 |
|---|---|---|---|
| 1 | IB Gateway 每天 04:00 ET 自动 reboot | 长跑脚本第二天连不上 | Phase 2 用 Task Scheduler 不长跑;Phase 3 加 reconnect 循环 |
| 2 | 节假日 market closed | 脚本跑但订单全 reject | pandas_market_calendars 提前 exit |
| 3 | Earnings 前后 IV 高 → spread 宽 | 滑点 > 50 bps | 在 universe 过滤掉「未来 7 天有 earnings」的标的 |
| 4 | Dividend ex-date 影响 mid | 价格跳空,mid 失真 | 不在 ex-date 前 2 个交易日触发 rebalance |
| 5 | 夏令时切换 | 触发时间偏 1 小时 | rebalance.py 入口检查 ET 时段 |
| 6 | Windows 睡眠 | Task Scheduler 没触发 | Power Options → 电源管理勾选「允许唤醒计算机」 |
| 7 | 重复触发(机器重启) | 重复下单 | 幂等闸 + runs 表去重 |
| 8 | yfinance rate limit | 数据拉一半失败 | 重试 + 缓存 + Phase 3 换付费数据源 |
| 9 | clientId 冲突 | 第二个进程连不上 | 用 PID 取模或环境变量管理 |
| 10 | SP100 成分变更 | 上月持仓的标的被踢出 | rebalance 时检查 current ∩ delisted,强制平仓 |
九、监控指标基线
每次 run 完,要在 monitor.py 里输出这几个指标到日志,长期跟踪:
| 指标 | 计算 | 健康阈值 | 含义 |
|---|---|---|---|
| fill rate | filled_qty / planned_qty | ≥ 95% | 订单成交比例 |
| slippage (bps) | (avg_fill - mid_at_submit) / mid × 10000 | ≤ 20 bps | 实际付出的执行成本 |
| turnover | sum(|trade|) / nav | ≤ 50% | 月度换手率(双因子月再平衡通常 30-40%) |
| time_to_fill | fill_ts - submit_ts | p50 ≤ 5 min | 流动性健康度 |
| tracking error | 实盘 PnL - 回测 PnL(每月) | ≤ 30 bps | 系统是否在按回测路径运行 |
| drift | 实际 weight vs target weight | ≤ 1 ppt | 再平衡是否到位 |
这些指标的月度漂移比绝对值更重要:fill rate 突然从 95% 跌到 80% 一定有事(要么市场出问题,要么我的代码 regression)。建议每个月把这 6 个数 append 到一个 CSV,做成 6 个月滚动图。
十、PM 视角:今天学到的迁移性思考
-
MVP → Beta 的工程成本是 5x:策略本身(signal_engine.py)30 行;其他三个模块加起来 300 行;加上调度、监控、错误处理、文档,最后 1000 行。这条规律在金融核心系统、电商、风控里完全一样——一个能 demo 的原型到能上线的产品,工程量永远是 5x 起步。
-
幂等是分布式系统的免疫力:今天三道闸(日期 / DB 状态 / force flag)的设计,与支付系统的「外部订单 ID + 状态机 + 重试键」是同一套思想。任何会被自动触发的逻辑,第一道题永远是「重复触发会怎样」。
-
熔断的位置很重要:
assert IB_PORT == 4002这种「断言式护栏」放在最早能放的地方——脚本开头,而不是函数里。故障检测距离故障源越近,事故影响半径越小——这条原则适用于支付反洗钱、合约 require、Web3 multisig。 -
dry-run / shadow mode 是产品安全网:今天每个有副作用的脚本都有
--dry-run,这与上线前的「灰度发布 / 流量染色 / 影子库对比」是同构的。没有 dry-run 的部署 = 没有刹车的车。 -
告警分级 = 注意力分配预算:P0-P3 四级不只是工程偏好,本质是你这个有限注意力个体的资源分配。所有「事事都告警」的系统最终等于「啥也不告警」(狼来了效应)。这一点和我做金融 PM 时设计风控规则的优先级完全一致——业务方说「越多越好」永远是错的。
-
从这一刻起我的系统有了「时间维度」:之前的 Day 1-35 都是 in-memory 的瞬时计算。从 Day 36 开始,SQLite 里的
runs表会跨月持续累积。系统第一次有了「状态」——这是从「脚本」升级到「产品」的本质标志。
十一、明日预告
Day 37: Phase 2 Week 5 复盘 — 部署到第一周后的真实反馈
- Day 31-36 这一周(Phase 2 第一周)的进度对比目标
- Day 36 部署的实际成交回顾:fill rate / 滑点 / 时间分布
- 双因子组合实盘 vs 回测的第一组数据点
- 学习节奏调整:是否需要把 AI 信号(原计划 Day 38-42)后推
- Week 5 review 模板的第一次填写
- 周末 reading list:3 篇生产化部署经验帖
实际执行记录
启动一项填一项,时间戳 + 卡点。
- [hh:mm] 模块化拆分完成 — Day 28 → 4 个文件
- [hh:mm] IB Gateway 启动 + Paper 登录 — 端口 4002 确认
- [hh:mm] SQLite schema 初始化 —
sqlite3 trade_log.sqlite < schema.sql - [hh:mm] dry-run 第一次跑 — plan 输出截图存档
- [hh:mm] 实跑 —
python rebalance.py --force - [hh:mm] 成交监控 30min — fill rate / 滑点记录
- [hh:mm] Task Scheduler 配置 — 下次触发:2026-07-01 21:30 BJT
- [hh:mm] 告警通道测试 — Bark 推送收到
- 监控指标基线(首次实跑):
- fill rate: __%
- 滑点 p50: __ bps
- turnover: __%
- time_to_fill p50: __ min
- 卡点 / 学到的:
总字数:约 7,200 字 今日完成度:理论 ✓ / 工程化拆分 ✓ / 部署 checklist ✓ / 首次实跑(你自己执行)/ 笔记 ✓