跳转至

M15 交易基础设施与实盘运营

层级:L4 实战与前沿 | 优先级:★★★ | 预计学时:14 小时 对应原书:陈第 4–5、8 章;黑箱第 7 章 前置知识:M04 的闸门;M13 的限价与实施缺口。 读书笔记:陈第 4–5 章的运营 状态:reviewed 数据截至:2026-04|最近复核:2026-10-28


学习目标

  1. 用六个维度比较券商,再决定接口,而不是先把某一家的示例代码跑通。
  2. 分清开发机和模拟盘所在机器:账本只有一个写入者,OpenD 只听回环地址。
  3. 顺着订单状态机看清「先落库,再提交」,以及 UNKNOWN 为什么停新单。
  4. 把个人系统画成数据、研究、交易、监控四层,并对应到 src/quant/。
  5. 用手算看每月确认 0.5 小时和每月维护 1 小时哪一边更省时间。
  6. 用净值 10 万的例子核对单笔 5% 和单名 10% 这两条示例上限。
  7. 用七个维度排查回测和账单之间的实施缺口。

本章知识拓扑图

flowchart TB
    DEV["开发机:改代码、推送"] --> GIT["远端 main"]
    GIT --> VPS["模拟盘机器:唯一写入者"]
    VPS --> OPEND["OpenD 只听 127.0.0.1"]
    DATA["数据层 Parquet"] --> RES["研究层"]
    RES --> SIG["信号表"]
    SIG --> APP["审批写入账本"]
    APP --> GATE{"SIMULATE、限价、额度、时钟"}
    GATE -->|通过| SEND["提交"]
    GATE -->|拒绝| STOP["不发"]
    SEND --> UNK["超时或未映射状态 → UNKNOWN"]
    UNK --> STOP
    SEND --> MON["监控:心跳、日终、告警"]

个人量化死掉的地方,常常不是因子公式,而是「研究笔记本里的目标权重」和「券商账户里的持仓」中间那一段没有主人。这一段要同时回答四件事:谁有权写账本,订单在哪一个状态上算结束,钱和额度够不够,以及成交之后的账单和回测差在哪一项。


🟢 入门层:先选约束,再选接口

1. 券商是系统的边界,不是数据源的别名

回测可以在任何一台装了 Python 的机器上重跑。成交只能发生在某一家券商的某一个账户里。换券商不是换一个 import,而是换费用、交易时段、能下的订单类型、模拟环境是否真的走同一套检查,以及账户出了问题找谁。下面六个维度是选择时要写进笔记的问题。数字以你当时拿到的价目表和接口说明为准,本章不抄一份会过期的费率卡。

维度 要问清的事 和本仓库的关系
成本结构 佣金、平台费、最低收费、港股印花税由谁代收 FeeSchedule 是回测输入。印花税在价目之外另加,见 M13、M18
接口能力与限流 能下哪些订单、查询和撤单是否共用额度、断线后订单还在不在 发送前有滑动窗口限流。窗口长度和次数是配置,不是写死的券商配额
市场覆盖 一个连接绑定一个市场,还是可以同时做港股和美股 富途路径按绑定的市场检查代码。跨市场要另开连接和账户
行情权限 研究和交易用的是不是同一套复权与公司行动 研究快照是 Parquet。成交仍按未复权价和整手,见 M18
模拟盘保真度 模拟环境是否要求显式声明,成交价是否像真实队列 省略环境不能当成模拟。idealized 按开盘价成交,不是发送路径的证据
监管与资产安全 钱在券商还是在你自己的进程里,密钥放在哪 密钥留在各机器本地。OpenD 只听回环地址。讲义不写地址和令牌

成本维度最容易被「免佣」带偏。免佣通常免的是券商佣金那一行,印花税、交易所收费、点差和未成交的机会还在。接口维度要分开看「文档里有 TWAP」和「你的进程会不会发 TWAP」。本仓库的富途发送路径只接受限价、常规时段、当日有效,送到网关的订单类型是普通限价。文档里的算法单不会因为你安装了 SDK 就自动出现。

市场覆盖要和策略的调仓日历一起看。港股和美股的常规时段不重叠。半日市会改掉「今天收盘之后发单」。2024-09-23 起港交所实施恶劣天气交易:8 号或以上台风信号和黑色暴雨警告期间,证券市场维持开市,不再把这类天气本身当成停市。回测若跨过这一天,此前的真实停市日保留,此后按开市;例外停市以港交所公告为准。日历怎么分成两段,见 M18 第 1 节和第 11 节。行情权限决定你能不能在研究里使用某一种复权;交易权限决定你能不能卖空、能不能做期权。本章的现货路径是只做多的限价单。卖空和期权即使在券商界面里看得到,也不自动进入这条闸门。

限流要和券商公布的配额分开记。仓库里的 RateLimiter 是进程自己的滑动窗口:给定最多多少次、窗口多长、两次之间至少隔多久。它不读取 OpenD 或 TWS 的公告。下面用一组教学参数把手算走完。最多 2 次,窗口 10 秒,最小间隔 1 秒。起点是窗口内还没有请求,等待是 0 秒,可以立刻记一笔。

0.4 秒后再问,距离上一笔只有 0.4 秒,最小间隔还差 1 − 0.4 = 0.6 秒。窗口里只有 1 笔,没有顶到 2 次的上限,所以等待由最小间隔决定,是 0.6 秒。在第 1 秒记下第二笔之后立刻再问:窗口里已经有 2 笔,最早的一笔还要 10 − 1 = 9 秒才滑出窗口。最小间隔只要求再等 1 秒,两者取更长的那个,等待是 9.0 秒。到第 10 秒,第一笔的年龄刚好满 10 秒,被移出窗口,剩下 1 笔,最小间隔也已经满足,等待回到 0。

这组 2 次和 10 秒不是任何一家券商的现行配额。它说明的是:次数上限和最小间隔会给出不同的等待,真正要等的是两者里更长的那个。次数预算用完时,继续重试只会把同一条意图打多次。未知状态已经停了新单之后,限流器不该被用来「换一个循环再送」。配额要写在配置里,并在价目或接口说明变更时改配置,而不是改状态机。

模拟盘保真度是个人投资者最容易高估的一项。模拟环境可以检查状态机、绑定和账本顺序。它不能制造真实队列里的部分成交,也不能代替日后小资金账单上的实施缺口。监管与资产安全则是另一层:API 进程崩溃不会让证券账户消失,但两台机器同时对同一个账户发单,会让同一条意图出去两次。所以下一节先定写入者,再谈接口长什么样。

2. 一台机器发单

研究、回测和改代码可以在多台开发机上做。模拟盘的账本和 OpenD 只在长期开机的那一台机器上。OpenD 的监听地址是回环地址 127.0.0.1,不面向公网。部署在调仓触发前 90 分钟内会拒绝,避免一边改代码一边发单。地址、密钥和告警令牌留在各机器的本地配置里。日常入口是仓库根目录的 vps.py:看状态、拉日志、在通过检查之后部署。细节在 ../../../deploy/README.md。

两台机器同时对同一个模拟账户发单,会把同一条意图送出去两次。写入者只有一个。开发机上的账户绑定已经退役。本机不重建绑定,也不跑会写账本或发单的命令。查询那台机器上的状态,用文档里的只读命令。

研究数据是另一条路。快照和日内文件不进 Git,以那台长期开机的机器为中转:缺了就取,新生成并核对后再发布。这条路搬的是 Parquet,不是账本,也不是密钥。

3. 半自动并不因为「更安全」就更省时间

每月调仓一次。半自动每次人工确认 0.5 小时,一年是

12 × 0.5 = 6

小时。全自动若要先投入 40 小时搭建,此后每月维护 1 小时,第一年是

40 + 12 × 1 = 52

小时,此后每年仍是 12 小时。

每月维护已经比每月确认多 0.5 小时。只要维护停在 1 小时,时间上没有回本的年份:此后每年是 12 对 6,差额每年再扩大 6 小时。只有把此后的维护算成 0,40 小时才用

40/6 = 6.667

年的确认时间抵回。全自动换来的是到点一定会跑、以及不会在确认时临时改权重。那是纪律,不是这张表里的小时数。

左:6 小时、52 小时和 12 小时。右:订单占净值;超过单笔 5% 示例的柱子被标出。单名 10% 是另一条线。

陈第 5 章的建议是先把流程跑稳。月度调仓可以一直用人确认,只要确认的是已经落账的计划,而不是盘中改目标。人在确认框里把 3,000 改成 6,000,那 0.5 小时就变成了另一条策略的研发时间。


🟡 进阶层:四层系统,以及发送前的账

4. 从数据到告警的四层

个人系统可以收成四层。上一层的产物是下一层的输入,不能绕过。

flowchart LR
    subgraph L1["数据层"]
        PQ["Parquet 快照与行情缓存"]
        CA["公司行动"]
    end
    subgraph L2["研究层"]
        FAC["因子与策略"]
        BT["回测与指标"]
    end
    subgraph L3["交易层"]
        SIG["信号 as_of / effective_at"]
        PORT["组合与额度"]
        RISK["独立风控闸门"]
        PLAN["订单计划"]
        ADAPT["Broker 适配"]
    end
    subgraph L4["监控层"]
        HB["心跳与看门狗"]
        EOD["日终与区间报告"]
        AL["告警"]
    end
    L1 --> L2 --> L3 --> L4
层 职责 本仓库目录
数据 把行情和公司行动写成可复核的文件 src/quant/data/:快照、缓存和日内条是 Parquet;日历、复权因子、质量检查在同一层。查询可以用任何能读 Parquet 的工具。仓库依赖的是 pyarrow,没有把 DuckDB 列为依赖
研究 在冻结数据上产生信号和回测 src/quant/research/、src/quant/factors/、src/quant/strategies/、src/quant/backtest/
交易 把信号变成带约束的订单,并在发送前拒绝 src/quant/core/signals.py 约束只做多、权重和不超过 1。src/quant/portfolio/ 做配置和估值。src/quant/risk/ 是闸门、波动和熔断。src/quant/execution/ 是计划、账本、状态机和 Broker
监控 进程还在不在、收盘后账是否对得上 src/quant/monitoring/:心跳、看门狗、日程核对、日终和区间报告、告警

账本是 SQLite,和行情 Parquet 分开。行情可以复制到研究机器上重跑。账本只有模拟盘那一台机器写入。策略代码不直接调用某一个券商类,它把目标交给交易层。Broker 的方法是连接、断开、列出账户、提交、撤单、查订单、查成交、查账户。富途实现和离线的 PaperBroker 都走这组方法。PaperBroker 的 external 是假,调用不会离开本机,用来把超时和乱序的回调排进测试。它仍然做绑定检查。

信号表上有两个时间。as_of 是这张表所依据的收盘,effective_at 是下一档可以交易的时刻。回测若用收盘价去乘同一根 K 线的收益,就是把还没发生的成交写进了信号。这个区分在 M03 已经算过。交易层只是不许它在发单时再出现一次。

5. 状态机不把 UNKNOWN 当结束

终态只有全部成交、已撤、已拒。UNKNOWN 的秩和「正在提交」同级,都是 2,但不是终态。超时、未映射的券商状态、提交过程里抛出的不确定错误,都进 UNKNOWN。账上只要还有这种意图,风控原因里就有 unknown_orders_open,新单不再发出。

人工把状态核对清楚之前,不能把 UNKNOWN 改成已撤或已成。否则同一条意图可能再被提交一次。允许的迁移写在 src/quant/execution/state.py:终态没有出边;UNKNOWN 只能走向券商已经说清楚的接受、部分成交、撤单中、已撤或已拒。

stateDiagram-v2
    [*] --> APPROVED: 审批已写入
    APPROVED --> SUBMITTING: 提交
    SUBMITTING --> ACKNOWLEDGED: 券商接受
    SUBMITTING --> UNKNOWN: 超时或未映射
    ACKNOWLEDGED --> FILLED: 全部成交
    ACKNOWLEDGED --> CANCELLED: 撤成
    UNKNOWN --> [*]: 人工核对后才离开

提交之前,审批和意图先写入账本,状态是已审批。写库失败就整批拒绝,内存里记下停发,不带着未落账的意图去调用券商。重放同一条意图键、订单号或成交号不会产生第二笔发送或第二笔记账。这个顺序在 submit_plan 里是先 record_approval 和 insert_intent,成功之后才进入发送。数据库不可写时,报告里的原因是 db_unwritable,函数返回,循环不会走到券商。

为什么不能「先发再补账」?重启会清掉内存。账本上没有这条意图,意图键就挡不住第二次发送。券商侧可能已经有订单。补写的账会和真实订单对不上,撤单也不知道撤哪一笔。先落库的代价是多一次写盘。后补账的代价是两笔订单。

6. 闸门在发送前把原因收齐

环境必须是已绑定的 SIMULATE。账户别名不对、模式是停新单、只减仓却在买、熔断之后还在买,都会留下原因。断线、时钟回拨、数据库不可写、没有进程锁、不在交易时段、日线不是新的,同样拒绝。

订单类型默认只放行限价。富途这条发送路径还要求常规时段、当日有效。送到网关的是普通限价,fill_outside_rth 为假。美股会话标成常规时段,港股路径使用网关要求的 NONE,因为港股没有同一套盘前盘后开关。市价要在限额里被显式允许,否则原因是 market_order_forbidden。

绑定检查拒绝一切不是 SIMULATE 的环境。富途在省略交易环境时会落到实盘,所以调用必须自己带上绑定的模拟环境,不能靠默认值。账户编号也必须等于绑定的那一个,不能取账户列表的第一行。

净值 100,000 的额度例子来自风险政策里的示例,不是唯一合法参数:

检查 数字 结果
单笔上限 5% 上限 5,000;订单 6,000 超过。6000/100000 = 0.06
单名上限 10% 已有 8,000,再买 3,000,合计 11,000 超过单名 10,000
现金 可用 1,000;卖出 500 尚未成交;想买 1,200 不能把未成交的卖出当成现金

买入用的是已经在账上的现金。先卖后买的顺序解决不了「卖单还没成交」。若错误地把 500 加进现金,可用看起来是 1,500,1,200 的买单还剩 300。正确的现金仍是 1,000,缺口是 200。6% 的单笔即使低于单名 10%,仍然过不了单笔 5%。右图里 6、8、11 三根柱子都高于 5% 这条线;8% 仍低于单名 10%。两条上限分开检查,过了较宽的那条不算过关。

7. 富途 OpenAPI 和 IB 的网关不是同一种默认值

两家都是「本机一个网关,策略进程通过网关说话」。差别在默认值会把单子送到哪里,以及本仓库已经实现了哪一条。

项目 富途 OpenAPI IB TWS API / ib_async
网关 本机 OpenD。本仓库要求监听回环地址 本机 TWS 或 IB Gateway。端口以当时的官方说明为准,不把记忆里的端口写进配置
协议 进程连 OpenD,再由 OpenD 对券商 ib_async 封装官方套接字接口。策略仍不应绕过 Broker
订单 本仓库只发限价、当日、常规时段,订单类型枚举是 NORMAL 接口族更宽,含多种有效期和算法单。本仓库没有 IB 的下单实现,不能把文档里的订单类型当成已经接入
算法单 不发送 TWAP、VWAP、收盘市价 同上。研究里的成交模型可以讨论这些名字,发送路径不调用它们
模拟环境 必须显式 SIMULATE。省略时期望落到实盘,绑定检查会拒绝 模拟和实盘通常是不同账户。仍然要在绑定里写明,不能靠「我连的是纸交易端口」这种口头约定
库的沿革 可选依赖 futu-api ib_insync 在 2024 年归档,后继是 ib_async。可选依赖写成 ib_async,不把某个次版本号写成必须安装的版本
模拟盘能证明什么 状态机、闸门、先落库 同一句话。队列里的部分成交要用真实账单

下面两段是参数形态,用来对照网关文档和 FutuBroker.submit_order。它们不导入券商库,不连接,不提交。

def limit_order_shape(
    symbol: str,
    side: str,
    quantity: int,
    price: float,
    remark: str,
    market: str,
) -> dict[str, object]:
    """组装富途 place_order 的参数形态。不连接 OpenD。"""
    # 开发机不导入 futu,也不调用 place_order。真正发送只发生在
    # 账本已经写下 APPROVED 之后,而且只在模拟盘那一台机器上。
    if side not in {"BUY", "SELL"}:
        raise ValueError("side")
    if quantity <= 0 or price <= 0:
        raise ValueError("quantity and price must be positive")
    if not remark.isascii() or len(remark.encode()) > 64:
        raise ValueError("remark")
    session = "RTH" if market == "US" else "NONE"
    return {
        "code": symbol,
        "trd_side": side,
        "qty": float(quantity),
        "price": float(price),
        "order_type": "NORMAL",
        "time_in_force": "DAY",
        "fill_outside_rth": False,
        "session": session,
        "remark": remark,
    }


def ib_limit_shape(side: str, quantity: int, limit_price: float) -> dict[str, object]:
    """ib_async 限价单的字段形态。本仓库没有 IB 下单实现。"""
    # 不构造 IB 连接,不调用 placeOrder。字段只说明:股票、限价、当日、不在常规时段外成交。
    if side not in {"BUY", "SELL"} or quantity <= 0 or limit_price <= 0:
        raise ValueError("bad limit shape")
    return {
        "secType": "STK",
        "orderType": "LMT",
        "action": side,
        "totalQuantity": quantity,
        "lmtPrice": limit_price,
        "tif": "DAY",
        "outsideRth": False,
    }

富途形态里,数量和价格在字典中是浮点,是因为网关那一层的参数如此。闸门在更早的地方要求数量是正的整股。备注必须是 ASCII 且不超过 64 字节,超长会在离开本机之前被拒绝。IB 形态里的 SMART 路由、交易所和合约乘数要到你真的接那一家时再按当时的合约定义填写。本章不给一个可执行的 connect。

8. 从回测到加仓的四段

晋级看的是运营是否守住已经写下来的约束,不是看某一段回测的夏普。工作流文档也没有把「夏普大于 1」当成门槛。

阶段 准入 淘汰
回测 信号用截止日之前的数据;费用按 M13 的口径进入净值;样本的幸存者偏差写在报告里 同一根 K 线既产生信号又充当成交;费用为零却拿去和实盘比
模拟盘 3 到 6 个月 每次调仓都有审批和意图;未知订单在下一笔新单之前被核对清楚;写入者仍是一台 出现第二个写入者;把 UNKNOWN 改成已撤后重发;在触发前 90 分钟内部署
小资金实盘 至少一个完整调仓周期有真实账单,并能按下一节拆开实施缺口 用模拟盘的理想开盘价代替账单;未成交卖出被记成现金
逐步加仓 单笔、单名、杠杆上限仍按政策执行;事故有记录 为了加仓放宽额度,或在确认时改数量

3 到 6 个月是陈的「先跑稳流程」在月频上的长度:大约 3 到 6 次调仓,足够看见漏发、重复发和时钟错误,不够看见一种因子的经济周期。小资金阶段要的是账单,不是把模拟盘的成交价乘一个乐观的滑点。加仓阶段的约束来自风险政策里的示例和已经接受的差异:杠杆上限 1.0,单名和单笔仍是额度问题。袖套单位净值回撤的停手线在 M04,不在本章重算。

把一个月写开。研究日收盘之后,数据层的 Parquet 已经包含当日条。研究层用截止到这个收盘的数据算出目标,信号的 as_of 是这个收盘,effective_at 是下一交易日开盘。交易层把目标收成订单计划,先算哈希。人确认的是这个哈希。确认通过后,审批和每条意图以已审批写入 SQLite。写成功,闸门才看环境、时段、未知订单、单笔、单名和现金。6,000 对 10 万净值、11,000 的单名合计、以及 1,200 对 1,000 现金,都在这里停住,不会出现在网关的成交回报里。

通过的那一条才进入限价形态:普通限价、当日有效、常规时段。提交过程若超时,意图进入 UNKNOWN,当天剩下的新单停止。次日开盘前要先做的不是新的研究,而是把这笔未知和券商订单查询对上。对上之后,监控层的日终报告才能把成交、费用和未成交分开。这个月的复盘填的是第 12 节的七行,不是一句「模拟盘跟上了回测」。

当月可以按下面的顺序留痕。每一行都对应上面某一层,缺一行就还停在模拟盘阶段:

序号 要点
1 快照文件的哈希与注册记录一致,公司行动进入复权因子。
2 信号的 as_of 不晚于用来计算它的最后一根收盘。
3 effective_at 不早于下一档可交易时刻。
4 发单机器上的提交号等于研究时的提交号。
5 部署没有落在触发前 90 分钟以内。
6 计划哈希已经写入审批,确认的人没有改数量。
7 意图状态在调用网关之前是已审批。
8 写库失败的那一批没有出现在网关订单里。
9 环境是绑定的 SIMULATE,账户编号不是列表第一行。
10 单笔、单名、现金三行都有通过或拒绝的原因。
11 未成交卖出没有被加进可用现金。
12 未知订单为零,或者已有人工核对记录。
13 成交价、费用、未成交股数能加总成实施缺口。
14 心跳和日终报告覆盖这一天。
15 写入者仍然只有一台机器。
16 下一月的目标仍然受杠杆上限 1.0 和额度示例约束。

9. 回测和实盘差在哪一层

差 核对
信号时点 实盘信号和用同一截止日重跑的目标是否同一张表
代码版本 发单机器上的提交是否就是研究时的提交
成交价 M13 的实施缺口:决策价、成交价、未成交
费用 账单对 FeeSchedule;价目是输入,不是券商合同
未成交 限价没越过极值、过去 20 日不足、参与率上限
现金 未成交卖出没有被拿去买

idealized 按开盘价成交,不看参与率,不是这条发送路径的证据。过去 20 个交易日的成交量不足时,参与率上限没有数,限价路径不成交。那一笔的机会项是整个订单,不是一个很小的滑点。

10. 开发机远程只读巡检与 ledger.db 核心表对账关系

模拟盘的 OpenD 和账本只在 VPS 上。开发机不重建 futu_binding.yaml,不在本机跑会发单或写账本的 paper 命令。巡检是远程只读:

uv run python scripts/ops/vps.py run -- paper status --config configs/paper/futu_simulate.yaml
uv run python scripts/ops/vps.py pull-artifacts

第一条在 VPS 上查看模拟盘状态,配置指向那里的 configs/paper/futu_simulate.yaml。第二条把 VPS 上的 reports/ 运行产物拉回开发机,再决定要不要提交。两条都不在开发机上打开交易解锁,也不改账本。

账本是 SQLite,表结构在 src/quant/execution/store.py。和一笔委托对得上的四张表是 approvals、order_intents、orders、fills。生产库用主键和唯一约束做幂等,没有把这些关联写成 SQL 外键。逻辑上仍是一条链:approvals.plan_hash 对上 order_intents.plan_hash;order_intents.intent_key 对上 orders.intent_key;orders.broker_order_id 对上 fills.broker_order_id。去重键是 intent_key、broker_order_id、fill_id。同一条意图、同一张券商订单、同一笔成交都只能落一次。

教学例子用内存库,不打开 VPS 上的 data/paper/futu/ledger.db。订单 100 股。两笔部分成交是 40 股 50.25 和 40 股 50.75。成交金额 40 × 50.25 + 40 × 50.75 = 4040,均价 4040 / 80 = 50.50。剩余未成交 100 − 80 = 20 股。

# goal8-check
"""一笔审批、一条意图、两笔部分成交。内存库,不写 VPS 账本。"""

import sqlite3
from decimal import Decimal

# 列是对账所需的子集。生产表的唯一键与这里相同。
SCHEMA = """
CREATE TABLE approvals (
    plan_hash TEXT PRIMARY KEY,
    approved_by TEXT NOT NULL
);
CREATE TABLE order_intents (
    intent_key TEXT PRIMARY KEY,
    plan_hash TEXT NOT NULL,
    quantity TEXT NOT NULL,
    broker_order_id TEXT UNIQUE
);
CREATE TABLE orders (
    broker_order_id TEXT PRIMARY KEY,
    intent_key TEXT NOT NULL UNIQUE
);
CREATE TABLE fills (
    fill_id TEXT PRIMARY KEY,
    broker_order_id TEXT NOT NULL,
    intent_key TEXT NOT NULL,
    quantity TEXT NOT NULL,
    price TEXT NOT NULL
);
"""

db = sqlite3.connect(":memory:")
db.executescript(SCHEMA)
# 先落审批,再写意图。写库失败时生产路径拒绝新单。
db.execute(
    "INSERT INTO approvals (plan_hash, approved_by) VALUES (?, ?)",
    ("plan-1", "owner"),
)
db.execute(
    "INSERT INTO order_intents "
    "(intent_key, plan_hash, quantity, broker_order_id) "
    "VALUES (?, ?, ?, ?)",
    ("intent-1", "plan-1", "100", "ord-1"),
)
db.execute(
    "INSERT INTO orders (broker_order_id, intent_key) VALUES (?, ?)",
    ("ord-1", "intent-1"),
)
# 成交号唯一。同一 fill_id 再插一次会被主键拒绝。
for fill_id, qty, price in (("f1", "40", "50.25"), ("f2", "40", "50.75")):
    db.execute(
        "INSERT INTO fills "
        "(fill_id, broker_order_id, intent_key, quantity, price) "
        "VALUES (?, ?, ?, ?, ?)",
        (fill_id, "ord-1", "intent-1", qty, price),
    )
rows = db.execute("SELECT quantity, price FROM fills").fetchall()
notional = sum((Decimal(qty) * Decimal(price) for qty, price in rows), Decimal("0"))
filled = sum((Decimal(qty) for qty, _price in rows), Decimal("0"))
average = notional / filled
remaining = Decimal("100") - filled
print(f"{average:.2f}")
print(f"{remaining:.0f}")
assert average == Decimal("50.50")
assert remaining == Decimal("20")

🔴 深入层:规则、事故和账单上的七项

11. 日内次数和保证金

SEC 于 2026-04-17 在 Federal Register 公布对 FINRA 提案 SR-FINRA-2025-017 的加速批准(Release 34-105226)。提案删除 pattern day trader、日内购买力和 25,000 美元最低权益,改为日内保证金。生效日是 FINRA 在 Regulatory Notice 里宣布的日期,文本写的是通知公布后 45 天;会员可以在通知公布后 18 个月内分阶段切换。写到 2026-10-22 时,不能默认每一家券商都已切完,也不要把生效日写成 2026-06-04 或把过渡期写成 2027-10-20。

多头股票的维持保证金长期要求仍在 Rule 4210 里,常见底线是 25%,以券商通知为准。次数限制放松之后,费用仍按每一元成交计。代码里不需要为了旧的「五个交易日三次」去拒绝模拟盘订单。保证金和单笔、单名上限仍要查。

断线、时钟异常、重复意图和极端行情,对应的动作已经在闸门里:断线拒绝,时钟回拨拒绝,未知订单拒绝新单,熔断之后不买。告警渠道的令牌只放在本机环境变量里。

税务不是本章的建议。成交和账单留在账本里,是为了事后能对上实施缺口。具体申报问税务顾问。

12. 实施缺口的七项排查

Perold(1988, Journal of Portfolio Management 14(3))把纸面组合和实际组合的差叫做实施缺口。M13 的手算可以当作一张总账:决策价 100,期末价 101,订单 100 股,成交 80 股、成交价 100.40,费用 1。纸面损益 100,实际损益 47,缺口 53,等于成交让步 32、费用 1、未成交机会 20。排查时不要只盯着 32。下面七项是把这 53 追回系统里的顺序。

  1. 信号时点。 目标权重的 as_of 是否早于成交。若信号用了成交之后的收盘,纸面里的 100 本身就是看得到未来的利润,缺口会被算得太小或太大。
  2. 代码版本。 发单机器上的提交是否等于研究时的提交。部署若发生在触发前 90 分钟内,研究表和发送表就不是同一份。
  3. 决策价到成交价。 80 股相对 100 的让步是 80 × 0.40 = 32。这一项是执行,不是研究误差。
  4. 费用。 账单上的 1 是否等于当时输入的 FeeSchedule。价目改了而回测没改,缺口里会多出一行固定成本。
  5. 未成交。 20 股乘上从 100 到 101 的上涨是 20。限价没有成交,不是节省了冲击,而是放弃了纸面收益。
  6. 参与率和行情窗口。 过去 20 日不足、或数量超过参与率上限时,未成交是规则使然。这时去调高滑点参数,对不上账单。
  7. 现金和额度。 模型想买 1,200,现金只有 1,000,订单在提交前就被拒绝。这项缺口不会出现在券商的成交回报里,因为它根本没被发送。账本上的拒绝原因才是证据。

七项的前两项属于研究是否和发送用了同一张表。中间三项是 M13 的加法。最后两项决定「模型里的订单」有没有离开闸门。小资金阶段至少对一个调仓周期把这七行填满。填不满的那一行,就是还不能加仓的原因。

13. 旧代码在 45 分钟里能做的事

Knight Capital 在 2012-08-01 约 45 分钟内发出大量错误订单。SEC 在 2013 年的行政程序里处理了市场接入和部署控制。公开转述里的亏损是数亿美元这一量级。常见写法在约 4.4 亿和约 4.6 亿之间,本章不从转述里再定一个精确数字。

对这条个人系统,对应的控制不是「我们资金小所以重复发送没关系」。一次只部署一台机器上的一个提交。订单有额度。未知状态停新单。写库失败不发单。部署窗口挡住调仓前的临场改代码。这些控制对 10 万净值和更大的账户是同一套。资金规模改变的是单笔美元数,不改变「同一条意图出去两次」的结构。

四层里每一层都有自己的失败,不要混成一句「系统坏了」。数据层的失败是快照哈希和注册的不一致,或者公司行动没有进复权因子。研究层的失败是用了成交之后的价格。交易层的失败是写库失败仍去调用网关,或者第二个进程也在写同一本账。监控层的失败是心跳停了却没有人看,未知订单过了一夜。日终报告对得上成交,不能证明研究用的 Parquet 就是发单那天的那一份。所以复盘要按层写,而不是只贴一张净值曲线。

限流教学参数也放在交易层,不放在券商的价目里。最多 2 次、窗口 10 秒、最小间隔 1 秒时,空窗口等待 0 秒,0.4 秒后的等待是 0.6 秒,两笔都记下之后的等待是 9.0 秒,第 10 秒第一笔滑出窗口,等待回到 0。把这组数换成券商文档里的配额时,比较的方法不变:次数上限和最小间隔取更长的等待。配额本身要随文档更新,不写死在讲义里。


真实案例

案例 1:确认时间不会自己变成纪律

一年 12 次、每次 0.5 小时,是 6 小时。加上 40 小时搭建和每月 1 小时维护,第一年 52 小时,以后每年 12 小时。在这组假设下,全自动的日历时间一直更多。它解决的是「人到点改了权重或忘了做」。

把场景写完整。每月第一个交易日收盘后,研究机器算出目标,计划哈希写进审批。确认的人打开的是这份哈希,不是一个可以改数量的空白单。若确认时把买单从 3,000 改成 6,000,新数量既可能撞上单笔 5%,也已经不是研究里的那条策略。半自动的 6 小时若花在改目标上,回测和账单的差会先出现在第 12 节的第 1 项和第 7 项,而不是出现在滑点里。

若你每月只有这 0.5 小时,就保持半自动,把时间用来核对哈希、未知订单和现金。把 40 小时花在自动化上,只有在你确信此后维护能降到 0、而且你真的会在确认时改权重时,才在小时账上划算。维护停在每月 1 小时,小时账不支持这次搭建。

案例 2:2012-08-01 的旧代码

45 分钟足够把错误订单送到市场。个人系统里对应的 45 分钟,可能是一次部署把旧的下单循环和新的目标表叠在一起,也可能是超时之后界面空白,操作者以为没送出而再点一次。

状态机把第二种情况收成 UNKNOWN。新单停止。人工用券商的订单查询对上订单号之后,才决定撤还是记成已成交。没有订单号时,撤单和重发都可能打在一笔已经存在的订单上。Knight 的行政程序讨论的是市场接入和部署控制。个人账户用同一类控制的缩小版:单一写入者、额度、未知即停、写失败不发。

案例 3:6,000 的单笔和未成交的 500

净值 100,000。6,000 是 6%,过不了 5% 的单笔示例。已有 8,000 再买 3,000,合计 11,000,过不了 10% 的单名示例。现金 1,000,想买 1,200,即使账上还有一笔 500 的卖出未成交,买入仍然不够。错误心算把现金看成 1,500,会以为还剩 300;正确缺口是 200。

这三笔都应该在提交前被拒绝,而不是送到 OpenD 再看券商怎么说。拒绝原因留在账本或发送报告里,供第 12 节的第 7 项使用。开发机上不跑这个提交。模拟盘那台机器上,也只有闸门通过之后才会走到网关。


方法论来源

概念 / 模型 核心文献或规范出处
日内次数 SEC Release 34-105226,Federal Register 2026-04-17。生效日跟着 FINRA 通告
单笔与单名 示例净值 100,000 时,单笔 5% 是 5,000,单名 10% 是 10,000
未知订单 未对账的未知状态拒绝新单,不是终态
先落库 审批写入 SQLite 之后才提交。写库失败就不发单
发送形状 限价、常规时段、当日有效。开发机不连接网关
账本 模拟盘账本只有 VPS 上一个写入者
图 figures/M15/plot_ops.py

💡 思考回顾与巩固

核心认知纠偏

误区 反例 用法
全自动第一年之后就更省时间 此后每年 12 小时,半自动是 6 小时 维护 1 小时时没有回本点
人确认所以可以改数量 确认的是已落账计划 改数量是另一条策略
UNKNOWN 过一会儿会自己变成已撤 它不是终态,而且停新单 人工核对
写库失败也可以先发单 审批写不进去就整批拒绝 先落库
卖出未成交可以拿来买 1,200 对现金 1,000,缺口 200 只用已入账现金
单名 10% 以内的单笔都能过 6% 仍高于单笔 5% 两条上限分开
省略交易环境会留在模拟盘 绑定检查拒绝非 SIMULATE 调用显式带环境
模拟盘成交就是实施缺口 纸面 100 与实际 47 的差要看账单 缺口用真实成交
文档里的 TWAP 已经接进系统 发送路径只有限价 算法单留在研究模型
日内次数规则已经在 2026-06-04 全部切换 生效日跟着 Regulatory Notice,会员可分 18 个月 以券商通知为准
开发机可以顺手发一笔模拟单 写入者只有模拟盘那一台 本机不发单
换券商只是换一个导入 费用、时段、模拟环境和资产托管都变 先写六维笔记

费曼自测

  1. 为什么每月维护 1 小时的全自动,在时间上永远追不上每月确认 0.5 小时?
  2. 审批已经在内存里,为什么写库失败仍然不能提交?
  3. 6% 的订单为什么在单名上限 10% 之下仍会被拒?
  4. 实施缺口 53 里面,哪两项不是「成交价贵了」?
自测要点 1. 此后每年是 12 小时对 6 小时,差额每年扩大 6 小时。40 小时的搭建只在维护被算成 0 时,才用 6.667 年抵回。纪律和省时不是同一个数。 2. 重启之后内存里的审批不在。账本上没有这条意图,就无法用意图键挡住第二次发送。写库失败把停发记下来,是为了不在账本和券商之间留下只有一边知道的订单。 3. 单笔示例是净值的 5%,6,000 对 100,000 是 6%。单名 10% 管的是持仓合计。右图里 6% 和 8% 都高于单笔线,8% 仍低于单名线。 4. 费用 1 是价目,不是价格让步。未成交的 20 是机会:20 股没有拿到从 100 到 101 的纸面收益。成交让步只有已成交的 80 股,即 32。三项相加才是 53。

四维习题

A. 概念题

  1. 模拟盘账本为什么只能有一个写入者?
  2. 终态有哪三个?UNKNOWN 为什么不在里面?
  3. 六个选券商的维度里,哪一个不能用「模拟盘能成交」来代替?
  4. 日内交易次数限制放松之后,哪两项检查仍然要留着?
  5. 四层架构里,策略为什么不直接调用富途的下单函数?

B. 计算题

  1. 每月确认 0.5 小时、搭建 40 小时、每月维护 1 小时。第一年和此后每年各是多少小时?维护改为 0 时,搭建要多少年抵回?
  2. 净值 100,000。订单 6,000 占净值的百分之几?已有 8,000 再买 3,000,是否同时违反单笔 5% 和单名 10%?
  3. 现金 1,000,未成交卖出 500,买入 1,200。若错误地把未成交卖出算进现金,看起来还剩多少?正确的缺口是多少?
  4. 决策价 100,期末价 101,100 股的订单成交 80 股、价格 100.40、费用 1。纸面、实际和缺口各是多少?32、1、20 分别是什么?

C. 编程实战题

  1. 运行 figures/M15/plot_ops.py,核对 6.0、52.0、12.0、回本 6.667,以及 6,000 和 11,000 两行 over True。
  2. 读 src/quant/execution/state.py 的终态集合和 src/quant/risk/gate.py 里 unknown_orders_open 出现的位置。用一句话写出:未知订单如何挡住新单。
  3. 调用本节的 limit_order_shape,市场填 US,备注用合法的短 ASCII。核对 order_type 是 NORMAL、session 是 RTH。不要在开发机导入券商库或连接网关。

D. 个人实战思考题

  1. 你每月只能拿出 0.5 小时。在这组时间假设下,全自动的哪一项好处不是小时数?
  2. 一笔限价单超时后界面没有状态。你要先做哪一件事,才谈得上撤单或重发?
  3. 券商通知说日内次数限制已经取消。你的单笔 5% 和单名 10% 示例要不要一起删掉?
  4. 模拟盘跑满 6 个月、没有未知订单。你是否已经可以按回测仓位加到政策上限?
完整参考答案与解析 **A1.** 两台机器同时写同一个模拟账户,同一条意图可能被发送两次。账本和 OpenD 只放在一台长期开机的机器上,开发机只改代码和推送。OpenD 只听回环地址。 **A2.** 终态是全部成交、已撤、已拒。`UNKNOWN` 表示提交结果不清楚,可能已经在券商侧存在。把它收进终态,就会在未核对时再发一笔。它的秩虽然和「正在提交」同为 2,但没有自动走向终态的边。 **A3.** 模拟盘保真度,以及监管与资产安全,都不能用「模拟环境成交了」来代替。成交价若是理想开盘价,队列里的部分成交仍然没被观察到。钱在券商账户里,密钥和写入者的约定也不因为模拟成交而改变。成本结构同样要看价目表,而不是看模拟盘是否收费。 **A4.** 保证金仍要按券商当时的 Rule 4210 口径检查,常见维持保证金底线是 25%。单笔和单名上限是额度,不随日内次数规则消失。费用仍按每一元成交计算。 **A5.** 策略若直接调用下单函数,就会绕过审批落库、额度、绑定和未知订单检查。`Broker` 把这些检查留在交易层。富途和离线纸交易都实现同一组方法,策略不依赖某一个类。 **B6.** 半自动每年 6.0 小时。全自动第一年 52.0 小时,此后每年 12.0 小时。维护为 0 时,40/6 = 6.667 年。维护为 1 小时时没有回本点。 **B7.** 6000/100000 = 0.06,即 6%,超过单笔 5%。持仓合计 11,000,超过单名 10,000。两条都违反。过了较宽的单名线,不能豁免单笔线。 **B8.** 错误地把 500 算进现金,可用变成 1,500,买入 1,200 看起来还剩 300。正确的现金仍是 1,000,缺口是 200。未成交卖出不能填这 200。 **B9.** 纸面 100 × (101 − 100) = 100。实际 80 × (101 − 100.40) − 1 = 47。缺口 100 − 47 = 53。32 是 80 股的成交让步,1 是费用,20 是 20 股未成交的机会。32 + 1 + 20 = 53。 **C10–C12.** 图不连接券商。下面只判断额度,不提交订单。
def cap_reasons(nav: float, order: float, name_position: float) -> list[str]:
    # 单笔和单名分开记。过了较宽的那条,不能豁免另一条。
    reasons = []
    if order > nav * 0.05:
        reasons.append("order")
    if name_position > nav * 0.10:
        reasons.append("name")
    return reasons


assert cap_reasons(100_000, 6_000, 11_000) == ["order", "name"]
assert cap_reasons(100_000, 4_000, 9_000) == []
`limit_order_shape` 只返回字典。美股会话是 `RTH`。不要在开发机调用网关。未知订单数大于 0 时拒绝新单。 **D13.** 好处是到点按已落账的计划执行,确认过程中不会改权重。小时数在维护 1 小时的假设下反而更多。若你每月的 0.5 小时用来改计划,那 6 小时并不对应原来的策略。 **D14.** 先看账本里的这条意图是不是 `UNKNOWN`,并停掉新单。没有和券商对上之前,不重发。撤单也要有订单号。没有订单号时,重发和撤单都可能打在一笔已经存在的订单上。 **D15.** 不删。次数限制和额度是两件事。2026-10-22 也不能默认每家券商都已完成切换。生效日跟着 FINRA 的 Regulatory Notice,会员还有最长 18 个月的分阶段期。 **D16.** 还没有。6 个月的模拟盘说明状态机和写入者守住了,没有说明真实队列里的部分成交。加仓前要有至少一个调仓周期的账单,并把第 12 节的七项填完。额度仍然是单笔 5%、单名 10% 和杠杆上限 1.0 这组政策,不因为模拟盘安静就放宽。

📌 章节知识总结

一句话核心洞察

  1. 选券商先写六维笔记:成本、接口与限流、市场、行情权限、模拟盘保真度、监管与资产安全。
  2. 模拟盘账本只有一个写入者。OpenD 只听回环地址。开发机不发单。
  3. 审批先写入账本,写失败就不提交。UNKNOWN 不是终态,未决时停新单。
  4. 数据是 Parquet,账本是 SQLite,策略不直接下单。发送路径只有限价。
  5. 每月确认 0.5 小时是每年 6 小时。搭建 40 小时、每月维护 1 小时,第一年 52 小时,此后每年 12 小时,时间上没有回本点。
  6. 净值 10 万时,6,000 过不了单笔 5%,8,000 再买 3,000 过不了单名 10%。未成交卖出不是现金。
  7. 实施缺口按信号时点、代码版本、成交让步、费用、未成交、参与率、现金额度七项追。M13 的 53 等于 32 + 1 + 20。

公式与样本

对象 数值 前提
半自动 每年 6.0 小时 12 次 × 0.5 小时
全自动 第一年 52.0,此后 12.0 搭建 40,每月维护 1
回本 仅当维护为 0 时是 6.667 年 用每年 6 小时去除 40
单笔 6,000 > 5,000 净值 100,000,示例 5%
单名 11,000 > 10,000 已有 8,000,再买 3,000
现金 缺口 200;错算看起来剩 300 现金 1,000,未成交卖出 500,想买 1,200
实施缺口 53 = 32 + 1 + 20 M13 手算,决策价 100,成交 80 股于 100.40
部署窗口 触发前 90 分钟拒绝 见部署说明
日内规则 Release 34-105226,2026-04-17 生效日为通知后 45 天,可分 18 个月
备注 最长 64 字节 ASCII 发送前本地拒绝
限流教学参数 空窗 0 秒;0.4 秒后再问等 0.6 秒;记满 2 笔后再问等 9.0 秒;第 10 秒回到 0 最多 2 次、窗口 10 秒、最小间隔 1 秒。不是券商配额

跨模块

结论 交给 位置
限价、参与率、实施缺口 成交质量 M13
杠杆 1.0、回撤熔断 闸门 M04,../../../docs/risk_policy.md
复权和整手 行情与下单数量 M18
状态机与先落库 发送 ../../../src/quant/execution/state.py,../../../src/quant/execution/service.py
拒因 下单前 ../../../src/quant/risk/gate.py
券商抽象 适配器 ../../../src/quant/execution/base.py,../../../src/quant/execution/brokers/futu.py
机器与命令 运维 ../../../deploy/README.md
月复盘 实盘和影子的差 ../../../journal/templates/monthly.md
小时与额度图 复算 ../figures/M15/plot_ops.py

学习指引

  • 精读:陈第 4–5 章怎样把研究和下单分成两套时间。黑箱第 7 章的基础设施,对照本章的单一写入者、四层目录和状态机。
  • 对照:小时和额度以脚本为准。缺口的 53 以 M13 为准。Knight 的美元数以 SEC 2013 年的命令为准,不从转述里再取一个精确值。
  • 项目:状态和日志用文档里的只读命令。接口形态只用来对照参数,不在开发机提交订单,不把地址和令牌写进笔记。
  • 下一步:M16 把机器学习和研究流程接上。模型不改变「先落库、未知就停」这道闸门。

自检清单

  • 能说出选券商的六个维度,并指出模拟成交不能代替账单
  • 能把系统收成数据、研究、交易、监控四层,并指出 Parquet 与 SQLite 各放什么
  • 能说出账本只有一个写入者,OpenD 只听回环地址
  • 能把 0.5 小时、40 小时和 1 小时算成 6、52 和 12,并说明维护为 1 小时时没有回本点
  • 能判断 6,000 和 11,000 分别违反哪一条示例上限
  • 能说明未成交卖出的 500 不能把现金从 1,000 变成 1,500,正确缺口是 200
  • 能说明 UNKNOWN 停新单,写库失败不提交
  • 能把实施缺口 53 拆成 32、1 和 20,并列出七项排查里现金额度为什么不会出现在成交回报中
  • 能复述日内规则的生效日跟着通知,而不是一个写死的 6 月 4 日
  • 能说明富途发送路径只有限价,limit_order_shape 不会连接网关