返回交易笔记
TR Day 2

ib_insync 进阶 — 连接、下单、查询、事件

ib_insync 同步/异步模型、Contract 合约限定、订单类型矩阵、账户/持仓查询、事件驱动模型、错误码

2026-05-11
Phase 1: 基础与工具链
IBKRib_insyncContractOrderTypesEventHandlersErrorCodes

日期: 2026-05-11 方向: 个人量化交易 / IBKR API 阶段: Phase 1: 基础与工具链 标签: #IBKR #ib_insync #Contract #OrderTypes #EventHandlers #ErrorCodes


今日目标

类型内容
学习ib_insync 同步/异步模型、Contract 合约限定、订单类型矩阵、账户/持仓查询、事件驱动模型、错误码
实操写一个完整的「连接 → 限价下单 → 取消 → 验证状态 → 断开」往返脚本,带异常处理与事件回调
产出TR-DAY2 笔记 + 可直接运行的下单往返脚本 + 错误码速查表

一、ib_insync 同步 vs 异步:搞清楚再写代码

1.1 ib_insync 不是「同步库」也不是「异步库」,它是「事件循环上的同步外壳」

很多人以为 ib_insync 是同步 API,其实底层完全是 asyncio 驱动。它做的事情是:把每个看似阻塞的方法(reqMktDataplaceOrder)内部 spin 一个事件循环,直到结果回来再返回。这意味着:

看起来实际上
ib.connect(...) 阻塞返回内部 loop.run_until_complete()
ib.qualifyContracts(c) 同步返回等待 server 回包,期间事件循环还在跑
ib.sleep(2)不是 time.sleep,是 loop.run_until_complete(asyncio.sleep(2))

核心后果:在 ib_insync 里绝对不能用 time.sleep——那会真正阻塞事件循环,导致回调收不到、连接被服务端踢掉。要等待用 ib.sleep(n)

1.2 util.startLoop() vs ib.run():什么场景用什么

运行环境调用方式说明
Jupyter / IPythonutil.startLoop() 一次,然后正常写同步风格代码因为 Jupyter 自己有 IPython kernel 的事件循环,需要 nest_asyncio 嵌套
Python 脚本(standalone)不用 startLoop,直接同步代码即可ib_insync 自动启动 asyncio 循环
服务化长跑ib.run() 进入 loop.run_forever()让事件循环常驻,靠 event handler 推动业务
混入已有 asyncio 项目await ib.connectAsync(...) 等 async 方法所有 IB 方法都有 *Async 版本

给我们的建议

  • 学习/开发:Jupyter + util.startLoop()。能看图能 print。
  • 回测脚本:standalone Python 脚本,不用任何特殊处理。
  • Phase 3 自动化:standalone 脚本 + ib.run(),靠 orderStatusEvent 推动状态机,配合 cron / systemd 跑。

1.3 什么时候必须切到 async 写法

90% 的个人量化场景不需要写 async。但下面这两类要:

  1. 同时连接多个 IB Gateway(分账户、分市场)。多个 IB 实例的 sleep/wait 互相干扰,必须 async 才能并发。
  2. 跟其他 async 框架共生(FastAPI / aiohttp 后端)。同步阻塞会卡住整个 Web server。

我们 90 天前 60 天都用同步写法,第 70 天起做 multi-account 时再切 async。


二、Contract 对象:金融实体的精确寻址

2.1 为什么 Contract 比股票代码复杂

「AAPL」这个字符串本身根本不能下单——同一个 ticker 可能在 NASDAQ、NYSE Arca、IEX 多个交易所交易;同一个标的可能有多个货币标价(BABA 在美/港);期权要再加上行权价、到期日、Call/Put、Multiplier。IBKR 需要你提供一个「唯一可识别」的 Contract 对象。

2.2 五种主要 Contract 类型

from ib_insync import Stock, Option, Future, Forex, Index, Bond

# 1) 股票 — 最常用
spy = Stock('SPY', 'SMART', 'USD')
# symbol  exchange  currency
#  SMART = IBKR 智能路由(自动选最优交易所)
#  ARCA / NASDAQ / NYSE = 指定交易所

# 2) 期权 — symbol + lastTradeDateOrContractMonth + strike + right + exchange
spy_call = Option('SPY', '20260619', 580, 'C', 'SMART', tradingClass='SPY')
# right: 'C' = Call, 'P' = Put
# tradingClass 在 SPX/SPXW 这种有「周/月」之分的合约上必填

# 3) 期货 — symbol + lastTradeDateOrContractMonth + exchange
es_dec = Future('ES', '202612', 'CME')
# 季月合约:H=3, M=6, U=9, Z=12

# 4) 外汇 — pair + exchange
eurusd = Forex('EURUSD', 'IDEALPRO')
# IDEALPRO 是 IBKR 的 FX 流动性池

# 5) 指数 — 用于查指数报价(不能直接交易,要交易得用期权或期货)
spx = Index('SPX', 'CBOE', 'USD')

2.3 qualifyContracts 为什么不是可选的

contract = Stock('AAPL', 'SMART', 'USD')
ib.qualifyContracts(contract)
# 调用之后 contract.conId 会被填充(IBKR 内部唯一 ID)
print(contract.conId)  # 例如 265598

为什么必须

  1. 歧义消解:同样写 Stock('BABA', 'SMART', 'USD') 时,IBKR 需要在美 ADR 和港股 ADR 间选;qualify 会让它确认或报错。
  2. conId 是订单引擎真正用的:所有下单/查报价/查持仓内部都按 conId 索引。没 qualify 过的 contract 在某些方法里能跑,但返回数据可能错位
  3. 失败 fail-fast:写错 ticker / 选错交易所 / 标的下市,qualify 直接抛异常,不会等到下单时才炸。

真实坑Stock('GOOGL', 'SMART', 'USD')Stock('GOOG', 'SMART', 'USD') 是不同 Class(A 类 vs C 类),千万别想当然。SPX 的「monthly」和「weekly」期权 tradingClass 也不同(SPX vs SPXW),不 qualify 会拿到错的 conId。

2.4 多结果时怎么办

某些查询会返回多个候选合约(典型场景:用 reqContractDetails 查所有可用期权链):

from ib_insync import Option

# 模糊匹配 — 不指定 strike 和 right,会返回完整链
template = Option('SPY', '20260619', 0, '', 'SMART')
details = ib.reqContractDetails(template)
print(f"找到 {len(details)} 个合约")
# details[i].contract 是完整 qualified contract

不要试图自己 join symbol + date + strike 拼合约——reqContractDetails 是规范方式。


三、订单类型:表面相似,行为差距巨大

3.1 七种核心订单类型对比

订单类型OrderType 字段行为适用场景注意
Market (MKT)MKT立即按市价成交必须立即成交(极少用)流动性差时滑点剧烈,期权基本禁用
Limit (LMT)LMT + lmtPrice不优于限价不成交大部分量化下单可能不成交,要监控状态
Stop (STP)STP + auxPrice触发后变 Market止损单触发后滑点不可控
Stop-Limit (STP LMT)STP LMT + auxPrice + lmtPrice触发后挂限价止损但限制最大滑点极端行情可能不成交导致止损失效
Trailing Stop (TRAIL)TRAIL + trailingPercentauxPrice价格反向走 X 后触发锁利 + 让利润奔跑IBKR 服务端跟踪,不需要本地保活
Bracket由 3 个 child order 组成父单成交后自动挂 take-profit + stop-loss入场即定 R/R一次下单即出场逻辑确定
Adaptive (IBALGO)MKT + algoStrategy='Adaptive'IBKR 路由优化滑点中等单量、可接受秒级延迟比纯 MKT 滑点小,比 LMT 成交率高

3.2 ib_insync 里的 Order 对象

from ib_insync import LimitOrder, MarketOrder, StopOrder, StopLimitOrder

# 限价买 SPY 100 股
o1 = LimitOrder('BUY', 100, 580.00)

# 市价卖 100 股
o2 = MarketOrder('SELL', 100)

# 止损卖 — 触发价 575
o3 = StopOrder('SELL', 100, 575.00)

# 止损限价卖 — 触发 575 后挂 limit 574
o4 = StopLimitOrder('SELL', 100, 574.00, 575.00)

3.3 IBKR 特有的关键字段

字段含义我们的用法
orderRef自定义字符串标签(≤16字节)写策略名 + 交易ID(如 mom_20260511_001),方便对账
tif (Time-in-Force)订单生命周期见下表
outsideRth是否盘前盘后能成交量化默认 False,避免极端价
transmit是否立即提交Bracket 父单 True,子单 False(链式提交)
account多账户时指定默认账户能省,多账户必填
whatIf试单(不真下)计算 margin 影响
hidden隐藏单(仅大单 ECN 支持)我们用不到

3.4 Time-in-Force(TIF)速查

TIF含义何时用
DAY当日有效,收盘自动取消默认值,绝大多数场景
GTC撤前有效(Good-Till-Cancelled)长期挂单(如套利远月期权)
IOC立即成交否则撤(Immediate-or-Cancel)抓瞬时流动性,部分成交可接受
FOK全部立即成交否则撤(Fill-or-Kill)大单要么全成要么不成
OPG仅开盘集合竞价想吃开盘价
GTD指定日期前有效财报前挂单到财报日

GTC 在 IBKR 默认最长 90 天会被服务端自动取消;想真正长挂要监控并续期。

3.5 Bracket Order:一次下单定下整个交易计划

from ib_insync import Stock

contract = Stock('SPY', 'SMART', 'USD')
ib.qualifyContracts(contract)

bracket = ib.bracketOrder(
    action='BUY',
    quantity=100,
    limitPrice=580.00,        # 入场限价
    takeProfitPrice=590.00,   # +$10 止盈
    stopLossPrice=575.00,     # -$5 止损
)
# bracket 是 [parent, takeProfit, stopLoss] 三个 Order
for o in bracket:
    ib.placeOrder(contract, o)

ib_insync 自动设置好 parentIdtransmit 字段。父单成交后子单自动激活并互为 OCO(One-Cancels-Other)—— 一旦止盈成交,止损自动撤;反之亦然。

为什么 Bracket 是个人量化的最佳实践

  1. 入场即把出场逻辑交给服务端,断网/电脑死机不影响 stop
  2. 强制你下单前想清 R/R,杜绝「看心情止损」。
  3. 撤单成本低,策略调整方便。

四、账户与持仓查询:四个方法各有用途

4.1 四个核心查询方法

方法返回推/拉延迟特性
reqAccountSummary()账户级聚合(NetLiq, BuyingPower, 等)订阅 + 推送启动时一次性快照 + 后续变化推送
reqPositions()所有持仓 list订阅 + 推送启动一次推全量,之后增量
reqPnL(account)账户级 daily PnL推送实时(盘中持续刷新)
reqPnLSingle(account, conId)单标的 PnL推送实时
reqOpenOrders()当前未成交订单一次拉调用时快照
reqAllOpenOrders()所有 client 的 open order一次拉调用时快照
reqExecutions()历史成交回报一次拉当日 + 历史(默认仅当日)

4.2 reqAccountSummary 返回的字段

AccountValue 里关键 tag:

Tag含义
NetLiquidation净清算价值(含浮盈浮亏)
TotalCashValue现金余额
BuyingPower可用购买力
GrossPositionValue持仓总市值
MaintMarginReq维持保证金
AvailableFunds可用资金 = NetLiq − 占用保证金
ExcessLiquidity超额流动性(接近 0 会被强平)
account = ib.managedAccounts()[0]
ib.reqAccountSummary()
ib.sleep(1)  # 让推送到达

netliq = next(v for v in ib.accountValues()
              if v.tag == 'NetLiquidation' and v.account == account)
print(f"Net Liq: {netliq.value} {netliq.currency}")

4.3 reqPositions 返回结构

positions = ib.positions()  # 同步 helper,内部 reqPositions + 等
for p in positions:
    print(f"{p.contract.symbol}: {p.position} @ avg {p.avgCost:.2f}")

Position 里的 avgCost期权是单张成本(不是 ×100 后的合约总成本),算盈亏时记得乘 multiplier。这是个人量化做期权策略最常见的「数字对不上」原因。

4.4 为什么不能只用 reqOpenOrders:推 vs 拉的认知

reqOpenOrders()——只反映你调用那一刻的状态。如果在两次拉之间订单状态变了(成交/部分成交/被券商拒绝),你不知道。

正确模式:reqOpenOrders 做启动时的状态恢复,靠 orderStatusEvent 做实时更新。下面第五章详述。


五、Event Handlers:交易系统必须事件驱动

5.1 为什么 polling 会出事

想象一个场景:你下了限价单,每 2 秒 reqOpenOrders() 检查是否成交。在两次检查之间:

  1. 订单成交 → 你延迟 2 秒才知道。
  2. 服务端拒单(比如保证金不足)→ 错误回调你没监听 → 你以为还在挂单 → 决策基于错的状态。
  3. 你的下一个信号触发 → 你以为没仓位实际有仓位 → 重复下单。

金融系统里 polling 不仅慢,是错的——状态变化是事件,你必须订阅。

5.2 ib_insync 的核心事件

事件触发时机回调签名
ib.orderStatusEvent订单状态变化(提交/部分成交/全成交/取消/拒绝)(trade: Trade)
ib.execDetailsEvent实际成交回报(含成交价/手续费)(trade, fill)
ib.errorEvent服务端报错(reqId, errorCode, errorString, contract)
ib.commissionReportEvent手续费回报(trade, fill, report)
ib.positionEvent持仓变化(position)
ib.pnlEventPnL 变化(pnl)
ib.disconnectedEvent连接断开()
ib.newOrderEvent新订单创建(trade)

5.3 注册回调的两种写法

# 方式 1: += 操作符
def on_order_status(trade):
    print(f"[STATUS] {trade.order.orderId} {trade.orderStatus.status} "
          f"filled={trade.orderStatus.filled}/{trade.order.totalQuantity}")

ib.orderStatusEvent += on_order_status

# 方式 2: 装饰器(如果只关心一个事件)
@ib.errorEvent
def on_error(reqId, code, msg, contract):
    print(f"[ERROR {code}] {msg}")

5.4 Trade 对象:状态机的载体

ib.placeOrder(contract, order) 返回一个 Trade 对象。这是个活对象——它的 orderStatusfillslog 字段会随着事件自动更新,不用你手动 refresh。

trade = ib.placeOrder(contract, order)
print(trade.orderStatus.status)   # 'PendingSubmit'
ib.sleep(1)
print(trade.orderStatus.status)   # 'Submitted'
# ... 成交后
print(trade.orderStatus.status)   # 'Filled'
print(trade.fills[0].execution.price, trade.fills[0].execution.shares)

5.5 OrderStatus 的状态枚举

PendingSubmit  -- 客户端已发,服务端未确认
PendingCancel  -- 取消请求已发
PreSubmitted   -- 服务端已收,但还在做风控/路由
Submitted      -- 已挂上交易所
ApiCancelled   -- 客户端发起取消,已被取消
Cancelled      -- 服务端取消(包括过期、撤单)
Filled         -- 全部成交
Inactive       -- 服务端拒绝/不接受(要看 errorEvent 知道为什么)

写状态机时:「Submitted」不代表会成交,「Inactive」要看错误码定原因,「Filled」时去看 fills 拿真实成交价(限价单可能成交在更优价)。


六、错误码速查:能省你 80% 的调试时间

6.1 错误码不是异常,是事件

ib_insync 不会对服务端错误抛 Python 异常——错误是通过 errorEvent 推送的。下面这些代码值得背:

Code含义严重度处理
200没有该合约 / 没数据警告检查 symbol/exchange
201订单被拒绝错误看附带 message:保证金不足/权限不够/价格异常
202订单已取消信息正常事件,不算错
300找不到 ticker ID警告你查一个未订阅过的 reqId
354没订阅市场数据错误去 IBKR 买订阅
399订单消息(不是错)信息比如 "Your order will not be placed at the exchange until...";可忽略
502连不上 TWS错误TWS 没开 / 端口错 / API 没启用
504未连接错误你在 ib.disconnect() 后还在用 ib
507不识别的消息错误通常是 ib_insync 与 TWS 版本不匹配
1100与 TWS 的连接断了严重准备重连
1101连接恢复(数据丢失)警告需要重新订阅市场数据
1102连接恢复(数据完整)信息可以继续
2104"Market data farm OK"信息启动时正常
2106"HMDS data farm OK"信息历史数据服务 OK
2158"Sec-def data farm OK"信息合约定义服务 OK
10089期权权限不足错误Options Level 没批到该策略需要的级别
10147OrderId 不存在(取消时)错误撤单的订单已不在队列
10148OrderId 不可取消(已成交/已取消)错误状态先于撤单到达

6.2 如何分类处理

INFO_CODES = {2104, 2106, 2158, 2107, 2119, 399, 202}
RECOVERABLE = {1100, 1101, 1102}
FATAL = {502, 504, 507}

def on_error(reqId, code, msg, contract):
    if code in INFO_CODES:
        return  # 静默
    if code in RECOVERABLE:
        print(f"[CONN] {code} {msg} — 准备重连")
        # trigger reconnect
        return
    if code in FATAL:
        raise RuntimeError(f"Fatal IBKR error {code}: {msg}")
    print(f"[ERROR {code}] reqId={reqId} {msg}")

ib.errorEvent += on_error

七、第一个完整往返脚本

7.1 设计目标

写一个能直接 python tr_day2_roundtrip.py 跑的脚本,做这五件事:

  1. 连接 Paper(带端口校验)
  2. 注册 errorEvent / orderStatusEvent
  3. Qualify SPY 合约
  4. 下一个远离市价的限价买单(确保不会成交)
  5. 立即取消该订单
  6. 等待状态稳定,打印订单生命周期日志
  7. 清理后断开

7.2 完整代码

"""
tr_day2_roundtrip.py
完整的「下单 → 撤单」往返脚本,演示连接、合约、下单、事件、取消、状态校验。

运行前提:
- TWS Paper 在 7497 端口运行
- 已 enable Read-Only 解除(API → Settings 取消勾选 Read-Only API)
"""

from ib_insync import IB, Stock, LimitOrder, util
import sys
import time

# ---------- 安全护栏 ----------
PAPER_PORT = 7497   # TWS Paper
LIVE_PORT  = 7496   # TWS Live — 任何脚本里都别碰这个
HOST = '127.0.0.1'
CLIENT_ID = 11

# ---------- 事件回调 ----------
INFO_CODES = {2104, 2106, 2107, 2119, 2158, 399, 202}

def install_event_handlers(ib: IB):
    def on_error(reqId, code, msg, contract):
        if code in INFO_CODES:
            return
        sym = contract.symbol if contract else '-'
        print(f"[ERROR {code}] reqId={reqId} sym={sym} msg={msg}")

    def on_order_status(trade):
        s = trade.orderStatus
        print(f"[STATUS] orderId={trade.order.orderId} "
              f"status={s.status} filled={s.filled}/{trade.order.totalQuantity} "
              f"avgFillPrice={s.avgFillPrice}")

    def on_exec(trade, fill):
        e = fill.execution
        print(f"[EXEC] {e.side} {e.shares} {trade.contract.symbol} "
              f"@ {e.price}  execId={e.execId}")

    def on_disconnect():
        print("[CONN] disconnected")

    ib.errorEvent       += on_error
    ib.orderStatusEvent += on_order_status
    ib.execDetailsEvent += on_exec
    ib.disconnectedEvent += on_disconnect


# ---------- 主流程 ----------
def main():
    ib = IB()
    install_event_handlers(ib)

    # 1) 连接 + 端口校验
    print(f"Connecting to {HOST}:{PAPER_PORT} clientId={CLIENT_ID} ...")
    try:
        ib.connect(HOST, PAPER_PORT, clientId=CLIENT_ID, timeout=10)
    except Exception as e:
        print(f"connect failed: {e}")
        sys.exit(1)

    assert ib.client.port == PAPER_PORT, \
        f"WRONG PORT {ib.client.port} — refuse to run on non-paper"
    print(f"Connected. server={ib.client.serverVersion()} "
          f"account={ib.managedAccounts()}")

    # 2) Qualify SPY
    spy = Stock('SPY', 'SMART', 'USD')
    qualified = ib.qualifyContracts(spy)
    if not qualified:
        print("qualify failed — check market data subscription")
        ib.disconnect()
        sys.exit(2)
    print(f"SPY qualified: conId={spy.conId}")

    # 3) 拿 snapshot 报价以决定一个安全(不会成交)的限价
    ticker = ib.reqMktData(spy, '', snapshot=True, regulatorySnapshot=False)
    ib.sleep(2)
    ref = ticker.last if ticker.last and ticker.last > 0 else (ticker.close or 580.0)
    safe_limit = round(ref * 0.5, 2)   # 50% 折扣:绝对不会成交
    print(f"SPY ref price: {ref}, safe_limit (won't fill): {safe_limit}")

    # 4) 下限价买单 1 股
    order = LimitOrder('BUY', 1, safe_limit)
    order.tif = 'DAY'
    order.orderRef = 'tr_day2_test_001'
    order.outsideRth = False

    trade = ib.placeOrder(spy, order)
    print(f"Order placed: orderId={trade.order.orderId}")

    # 5) 等订单状态稳定到 Submitted / PreSubmitted
    deadline = time.time() + 10
    while trade.orderStatus.status not in ('Submitted', 'PreSubmitted',
                                           'Cancelled', 'Inactive', 'Filled'):
        ib.sleep(0.5)
        if time.time() > deadline:
            print("timeout waiting for order to settle")
            break
    print(f"Order settled: status={trade.orderStatus.status}")

    # 6) 立即取消
    if trade.orderStatus.status in ('Submitted', 'PreSubmitted'):
        ib.cancelOrder(order)
        print("cancel requested")
        deadline = time.time() + 10
        while trade.orderStatus.status not in ('Cancelled', 'Filled', 'Inactive'):
            ib.sleep(0.5)
            if time.time() > deadline:
                print("timeout waiting for cancel")
                break

    # 7) 总结
    print("---- final ----")
    print(f"orderId={trade.order.orderId}")
    print(f"status={trade.orderStatus.status}")
    print(f"filled={trade.orderStatus.filled}")
    print(f"log:")
    for entry in trade.log:
        print(f"  {entry.time}  {entry.status}  {entry.message}")

    # 8) 断开
    ib.disconnect()
    print("disconnected. done.")


if __name__ == '__main__':
    main()

7.3 预期输出

Connecting to 127.0.0.1:7497 clientId=11 ...
Connected. server=176 account=['DUH123456']
SPY qualified: conId=756733
SPY ref price: 580.42, safe_limit (won't fill): 290.21
Order placed: orderId=1
[STATUS] orderId=1 status=PreSubmitted filled=0/1 avgFillPrice=0.0
[STATUS] orderId=1 status=Submitted filled=0/1 avgFillPrice=0.0
Order settled: status=Submitted
cancel requested
[STATUS] orderId=1 status=PendingCancel filled=0/1 avgFillPrice=0.0
[STATUS] orderId=1 status=Cancelled filled=0/1 avgFillPrice=0.0
---- final ----
orderId=1
status=Cancelled
filled=0
log:
  2026-05-11 14:22:01.234  PendingSubmit  -
  2026-05-11 14:22:01.456  PreSubmitted   -
  2026-05-11 14:22:01.678  Submitted      -
  2026-05-11 14:22:02.123  PendingCancel  -
  2026-05-11 14:22:02.345  Cancelled      -
disconnected. done.

7.4 这段脚本里的几个关键工程决定

决定为什么
端口 assert防误连实盘——量化最贵的 bug 是连错环境。
safe_limit = ref × 0.5故意远离市价,确保不成交,否则 Paper 测试会真扣(虚拟)钱,破坏可重复性。
orderRef 带策略名后期对账靠它 group。生产里推荐 <strategy>_<utc_ts>_<seq>
状态轮询有 timeout网络抖动 / TWS 卡死时不挂死脚本。
事件 handler 在 connect 注册否则 connect 时的初始事件(2104/2106)会丢。
disconnectedEvent 监听真实长跑里要触发重连;这里只打印。

八、PM 视角:今天学到的迁移性思考

  1. 同步外壳 + 异步内核是一种成熟的 SDK 模式。它让 90% 的用户写最简单的代码,又给 10% 的高阶用户留了 async 接口。我们做内部 SDK 时也该考虑:默认同步 + 提供 async 后门,比强制 async「政治正确」更友好。

  2. Contract 对象 = 金融实体的强类型化。十年金融系统经验里,「symbol 是 string」是无数 bug 的根源——SPX vs SPXW、GOOG vs GOOGL、HK BABA vs US BABA。强类型 Contract + qualifyContracts 是行业沉淀出的最佳实践,迁移到任何金融系统都成立:永远不要让业务对象用 string 作主键

  3. 状态机优于轮询Trade 对象本质是个自更新的状态机视图,由事件流驱动。这是金融/支付/风控系统最该长成的样子——不是「定时去 query 状态」,而是「订阅状态变化」。我们做核心系统时,事件流(Kafka / NATS / 内部 EventBus)+ 状态投影比轮询数据库可靠 10 倍。

  4. 错误码 ≠ 异常。IBKR 的错误是通过事件流推送的「事实」而不是控制流的「中断」。这个抽象很值得学:不是所有「不正常」都该 throw——很多只是「需要业务感知」的事件。把它们建模成事件而不是异常,能让上层逻辑更清晰。

  5. Bracket Order 是「把出场写死在入场时」的体现。这背后是更深的产品哲学:人在情绪低谷时做的决定最差,所以把出场决定前置到情绪好的时候。这条放在量化叫 pre-commitment,放在产品叫 default-by-design,放在监管叫 cooling-off period——都是同一件事。


九、Day 2 Checklist

按这个顺序做:

  • (1) 把昨天的 tr_day1_connect.py 跑一遍,确认 Paper 还能连
  • (2) 阅读本笔记 § 一/二/三,理解同步外壳、Contract、订单类型矩阵
  • (3) 在 TWS API Settings 里取消勾选 Read-Only API(昨天为了安全勾上的),保存
  • (4)tr_day2_roundtrip.py 抄到本地,运行,看是否输出 Cancelled
  • (5) 故意把 safe_limit 改成接近市价(ref + 0.01)的值,再跑一次,观察是否真的 Filled
  • (6) 故意把 PAPER_PORT 改成 7496,确认 assert 会拦下脚本
  • (7) 改写 on_order_status,把每次状态变化追加写到 orders.log 文件
  • (8) 在 ipython 里 from ib_insync import *; util.startLoop(),交互式跑一下 ib.positions() / ib.accountSummary()
  • (9) 更新 docs/daily/TR_PROGRESS.md Day 2 标 ✓
  • (10) 在「实际执行记录」段补上踩到的坑

十、明日预告

Day 3:行情数据 — 实时报价、Tick、Bar、历史数据与 backfill

  • reqMktData 流式 vs snapshot 一次性
  • Tick types 详解:bid / ask / last / volume / 还有几十种你可能不知道的
  • reqTickByTickData:tick-by-tick(要订阅 enhanced data)
  • reqHistoricalData:分钟/小时/日 bar,what to show(TRADES / MIDPOINT / BID / ASK)
  • reqHistoricalTicks:拉历史 tick(最多 1000 个/次)
  • 数据持久化:用 parquet 存历史 bar,用 DuckDB 直接 query
  • 第一个数据 pipeline:每天收盘后自动 backfill SPY 1m bar 到本地

实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] 取消 Read-Only API —
  • [hh:mm] roundtrip 脚本第一次成功跑通 —
  • [hh:mm] 故意成交版本(safe_limit 改近)—
  • [hh:mm] 端口 assert 测试 —
  • [hh:mm] 持仓/账户摘要交互式查询 —
  • 卡点 / 学到的:

总字数:约 6,500 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓