返回交易笔记
TR Day 36

IBKR Paper Trade 部署 — 月度自动再平衡

从回测脚本到 paper trade 系统的工程化原则、月度再平衡的幂等设计、IB Gateway 长跑的可用性 trade-off、生产化监控的指标体系

2026-06-14
Phase 2: 策略实战 + AI 信号
PaperTradeDeploymentIBKRib_insyncSchedulerMonitoringProduction

日期: 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.pyTask 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))

幂等设计的三道闸

  1. is_first_trading_day — 不是月初不跑
  2. already_ran_this_month — 本月成功过不跑
  3. --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 SchedulerB: APScheduler 长跑
复杂度低(系统自带)中(需要 supervisor)
可靠性高(OS 级)中(进程崩了就停)
IB Gateway 重连不需要(每月只跑 30 分钟)必须(IBG 每天 04:00 ET 重启)
故障恢复重启电脑也没事需要 systemd / nssm 守护
监控Task Scheduler 历史自己写 health endpoint
适合场景月度 / 周度低频日内高频

今天的选择:A。原因:

  1. 月度再平衡,跑完就退,根本不需要长跑
  2. Phase 2 的目标是「跑通流程」,引入 APScheduler 多一层复杂度,故障面扩大
  3. 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 次 → failP0
TWS API 端口被占用罕见换 clientId 再试P1
市场数据 timeout(某标的)偶尔skip 该标的,记录在 run_logP2
订单 reject (no permission)罕见(paper 一般不会)log + skipP1
订单 reject (insufficient funds)可能log + 跳过剩余 BUYP0
部分成交(limit at mid 没全成)经常30 分钟超时 → cancel;保留持仓不平P2
节假日跑了脚本罕见NYSE calendar check 提前 exitP3
缓存 parquet 损坏罕见删文件重新拉P2
网络抖动经常retryP3

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)

#现象防御
1IB Gateway 每天 04:00 ET 自动 reboot长跑脚本第二天连不上Phase 2 用 Task Scheduler 不长跑;Phase 3 加 reconnect 循环
2节假日 market closed脚本跑但订单全 rejectpandas_market_calendars 提前 exit
3Earnings 前后 IV 高 → spread 宽滑点 > 50 bps在 universe 过滤掉「未来 7 天有 earnings」的标的
4Dividend ex-date 影响 mid价格跳空,mid 失真不在 ex-date 前 2 个交易日触发 rebalance
5夏令时切换触发时间偏 1 小时rebalance.py 入口检查 ET 时段
6Windows 睡眠Task Scheduler 没触发Power Options → 电源管理勾选「允许唤醒计算机」
7重复触发(机器重启)重复下单幂等闸 + runs 表去重
8yfinance rate limit数据拉一半失败重试 + 缓存 + Phase 3 换付费数据源
9clientId 冲突第二个进程连不上用 PID 取模或环境变量管理
10SP100 成分变更上月持仓的标的被踢出rebalance 时检查 current ∩ delisted,强制平仓

九、监控指标基线

每次 run 完,要在 monitor.py 里输出这几个指标到日志,长期跟踪:

指标计算健康阈值含义
fill ratefilled_qty / planned_qty≥ 95%订单成交比例
slippage (bps)(avg_fill - mid_at_submit) / mid × 10000≤ 20 bps实际付出的执行成本
turnoversum(|trade|) / nav≤ 50%月度换手率(双因子月再平衡通常 30-40%)
time_to_fillfill_ts - submit_tsp50 ≤ 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 视角:今天学到的迁移性思考

  1. MVP → Beta 的工程成本是 5x:策略本身(signal_engine.py)30 行;其他三个模块加起来 300 行;加上调度、监控、错误处理、文档,最后 1000 行。这条规律在金融核心系统、电商、风控里完全一样——一个能 demo 的原型到能上线的产品,工程量永远是 5x 起步。

  2. 幂等是分布式系统的免疫力:今天三道闸(日期 / DB 状态 / force flag)的设计,与支付系统的「外部订单 ID + 状态机 + 重试键」是同一套思想。任何会被自动触发的逻辑,第一道题永远是「重复触发会怎样」。

  3. 熔断的位置很重要assert IB_PORT == 4002 这种「断言式护栏」放在最早能放的地方——脚本开头,而不是函数里。故障检测距离故障源越近,事故影响半径越小——这条原则适用于支付反洗钱、合约 require、Web3 multisig。

  4. dry-run / shadow mode 是产品安全网:今天每个有副作用的脚本都有 --dry-run,这与上线前的「灰度发布 / 流量染色 / 影子库对比」是同构的。没有 dry-run 的部署 = 没有刹车的车

  5. 告警分级 = 注意力分配预算:P0-P3 四级不只是工程偏好,本质是你这个有限注意力个体的资源分配。所有「事事都告警」的系统最终等于「啥也不告警」(狼来了效应)。这一点和我做金融 PM 时设计风控规则的优先级完全一致——业务方说「越多越好」永远是错的。

  6. 从这一刻起我的系统有了「时间维度」:之前的 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 ✓ / 首次实跑(你自己执行)/ 笔记 ✓