土法炼钢兴趣小组的算法知识备份

【量化交易】运维与合规:监控、熔断、监管报送、复盘

文章导航

分类入口
quant
标签入口
#ops#compliance#monitoring#circuit-breaker#post-mortem

目录

把交易系统搭起来只是开始。一个能跑回测、能下出第一笔单的系统,距离能托管真金白银还隔着一整套运维与合规设施:监控、告警、熔断、降级、监管报送、事故复盘、变更评审、灰度、混沌演练。这一套东西的存在感很低,写得再好也只在事故发生的几分钟里被人想起;但凡省掉一项,账户被打穿之前都不会有人察觉。

这一篇要把这层「不出事看不出价值、出了事就是全部价值」的设施写清楚。前置阅读是上一篇《交易系统架构》——本文假定读者已经接受了「研究、回测、纸交易、生产」四套环境共用核心抽象的设定,以及第十九章《风控引擎》和第二十四章《可靠性与容灾》中对于幂等、限流、隔离、灾备的一般原则。本文不重复这些通用工程结论,只补量化交易场景下的特殊性:决策窗口是毫秒级的、错误成本以现金计、监管不接受「事后修复」、复盘的目标不只是改 bug 还要改激励结构。

代码示例使用 Python 3.11、asyncioprometheus_client 0.19。本文给出的实时风控守护进程是一个最小可运行版本,演示订单速率限制(rate limiting)、回撤监控(drawdown monitoring)和熔断触发(circuit breaker)三条主路径,同步保留了把它接到真实订单管理系统(order management system, OMS)所需要的扩展点。

风险提示:本文涉及的监管条款条文编号、阈值参数、事故年表、事故金额来自公开信息源,但具体数字可能随合规更新而变化。任何实际系统的熔断阈值、报送字段、值班流程都必须以本机构所在司法辖区当时生效的最新规则为准,并经合规、法务、交易、技术四方共同评审;不应把本文示例代码或阈值直接照搬到生产系统。复盘案例的因果分析是工程视角下的简化,不构成对相关人员或机构的法律评价,也不应据此推断未公开的事实。本文不构成任何投资建议、合规建议或法律建议。


一、量化交易的运维独特性

普通互联网服务的可用性故事是:「99.95% 可用,故障 5 分钟内恢复,用户少看几条朋友圈」。量化交易的故事是:「99.95% 可用意味着每年 4 小时 22 分钟的盲飞,期间任何持仓的 mark-to-market 偏离都不可见,任何成交回报都会丢,任何挂单都不能撤」。同一个数字在两个语境下的含义完全不同。把互联网行业的 SRE 直觉直接搬到交易系统上,最容易翻车。

一点一、故障窗口的时间尺度

行情数据流出现 200 毫秒级的卡顿,对一家 CDN 来说是测量噪声,对一支以毫秒为决策周期的做市策略来说就是「在过去 200 毫秒里你已经按 200 毫秒前的价格挂出了上千笔订单,对手方已经全部 take 走,账户被洗了一遍」。同样地,对于一个分钟级的因子策略,主行情源到备行情源切换需要的 5 秒钟可能根本没有可观察的 PnL 影响,但同一个 5 秒钟切换在做市这边足以让一支策略当天 KO。

这意味着:故障窗口必须按策略的决策周期来定义,而不是按基础设施 SLA 的 9 的个数来定义。在监控里把所有故障窗口都拉成同一根时间轴是失败的设计;正确做法是给每一类指标配它自己的时间窗口,下面第二节会展开。

一点二、错误成本的金钱化

CDN 故障的成本是「广告主退款」「用户流失」,可以平摊到月度报表里抹平。交易系统故障的成本是即时清算的:一秒钟内多发出 3 万笔错误订单,账户里的钱就消失了,钱不会因为你事后 root cause 写得好就回来。Knight Capital 在 2012 年 8 月 1 日的 45 分钟里损失 4.6 亿美元,本质就是这种错误成本即时清算的属性,和「服务降级了用户体验差」完全不是一类问题。

这条属性派生出两个工程结论:

一点三、可观测性必须穿透到业务语义

在普通后端系统里,监控到 HTTP 5xx、p99 延迟、CPU、内存、磁盘 IO 大体就够。交易系统不行:CPU 一切正常、HTTP 全是 200、p99 延迟没变化,但策略已经在错的方向上累积了一个意外的几千万人民币敞口——因为某只股票当天停牌却没有从 universe 里被剔除,因子值瞬间被一条脏数据污染。这一类故障的特征是「基础设施全绿,业务全红」,必须靠业务层指标才能发现。

一句话总结这一节:量化交易的运维难在错误成本即时清算、决策窗口毫秒级、故障必须能从基础设施一直追到 PnL 归因。下文所有设计都围绕这三条展开。


二、监控体系

量化系统的监控不是一个 Grafana 大屏能解决的问题。它的本质是把从「机器是否还活着」到「策略是否还在赚钱」这条链条上的每一层都做成可独立判定的健康信号,并保证任何一层异常都能向下溯源到具体的 tick 或订单。下面分六层给出实操框架。

监控分层

二点一、L1:基础设施层

包含 CPU、内存、磁盘、网卡、NTP 时钟漂移、内核错误日志。这一层和任何后端系统没区别,可以直接用 node_exporter + Prometheus + Grafana 这一套。需要特殊关注的只有时钟:交易所要求时间戳同步到毫秒级(A 股某些场景要求毫秒级,US 市场对 OATS / CAT 报送要求 50 毫秒以内同步精度),任何 NTP / PTP 异常都要能即时告警。

时钟监控的具体指标是 chronyptp4l 暴露出来的相位偏差(offset),当 |offset| 持续超过 1 毫秒就告警。把它和后面的订单时间戳归因连起来,可以在出现成交回报序列错位时快速判定是「网络抖动」还是「时钟漂移」。

二点二、L2:行情数据流层

最容易被忽略的一层。指标至少包括:

这一层指标采集必须内嵌在 feed handler 进程内,而不是从下游观察。从 OMS 那一侧观察「最近 1 秒没收到任何 tick」永远会比 feed handler 自己内省晚一拍——后者能看到「socket 还在但已经没有数据」「序列号回到了 0」「时间戳跳回历史」这类微妙异常。

二点三、L3:策略与信号层

策略进程内部要打的指标包括:

二点四、L4:订单与成交层

这一层是最容易做指标膨胀也最容易做错的:

二点五、L5:风险敞口层

实时仓位、现金、保证金、敞口分解都要在线计算并暴露成指标,不能只在收盘后跑批。具体维度至少包括:

这层指标要和回测期内允许的同名约束比对:研究阶段说「单标的敞口不超过 NAV 的 5%」,生产里就要在 2% 预警、4% 软熔断、5% 硬熔断。具体熔断行为见第四节。

二点六、L6:盈亏与归因层

最顶层,最贴近业务,最容易造成幻觉。要监控的不是 PnL 本身——PnL 短期波动是策略本身的属性,盯它会带来无效告警——而是 PnL 与预期的偏离:

每一层告警都要写明三件事:采集源、阈值依据、告警严重度。下一节展开告警治理。


三、告警与值班

监控指标摆得再多,如果告警一响就是几百条、值班的人三分钟内全部点掉,这套监控等于不存在。告警治理的核心问题是把信噪比拉到足够高,让每一条响起来的告警都值得花一分钟去看。

三点一、告警分级

实践中至少要分四级:

每条告警都要在创建时打上分级;分级在事件回放里要可统计,每月看一次「P0 是不是漏掉过」「P3 是不是有人长期不修」。

三点二、告警抑制与去重

最常见的反模式是「一个根因爆出 200 条告警」。常见场景是行情源掉线,下游所有策略的「数据延迟」「因子分布异常」「订单速率为零」全都同时报警。处理办法是给告警之间显式建依赖:

Prometheus / Alertmanager 都原生支持这三类规则。规则本身要进版本控制,每次修改都走代码审查,不允许在 Web UI 里直接改。

三点三、值班制度

值班(on-call)不是「24 小时电话开机」这么简单。一个能扛得住交易日的值班制度至少要写明:

三点三bis、事件管理的工具链

对于多个交易系统同时运行、多个策略团队共用一套基建的场景,需要一套事件管理的工具来避免混乱:

三点四、ChatOps

「告警去 IM 群」并不是 ChatOps,那只是把邮件搬到 Slack。真正的 ChatOps 是把运维操作变成 IM 里的命令,所有命令都有:审计、确认、回滚。常见的命令骨架:

/halt strategy=foo reason="DD breach"          # 软熔断指定策略
/freeze account=acc1 reason="post-trade audit" # 冻结账户新单
/cancel-all strategy=foo                       # 撤所有挂单
/resume strategy=foo approver=@alice           # 恢复,需要双人复核
/dump position account=acc1                    # 导出持仓快照

每条命令的执行都会在频道里留一条结构化记录(who、what、when、approver、result),同时落到运维库里,事后复盘和监管问询都能调出来。和邮件相比,ChatOps 的核心好处是把「群里讨论」和「实际操作」放在同一条时间线上,避免事后拼接出错。

值得强调:ChatOps 命令的执行权限要走 SSO 和 RBAC,IM 账号本身不是身份凭证。任何敏感命令都要双人复核(two-person rule),第二节里的 /resume 就是一个例子。


四、熔断与降级

监控告警是被动的,熔断降级是主动的。监控告诉你「出事了」,熔断决定「自动停到什么程度」。一个成熟系统在 99% 的事故里不依赖人来按按钮——人来得太慢,靠人在交易时段做决策的系统在 Knight Capital 那种事故面前就是 4.6 亿美元的成本。

熔断的设计原则只有一条:自动化的方向只能是「更保守」,绝不能是「自动恢复并继续下单」。下面把具体决策树拆开。

熔断决策树

四点一、按触发源分类

最常见的三类触发源:

也有按外部源触发的:交易所发停牌通告、撮合引擎延迟突然拉高、系统时钟漂移、上游清算商发出 margin call 预警。这些都要纳入熔断条件。

四点二、按动作分级

熔断不是一刀切。从轻到重:

  1. 限速(throttle):把订单 token bucket 调小一档,例如从 100/s 到 30/s。策略继续运行,但被强制慢下来。这一档处置常用于「拒单率轻微上升、暂时还判断不了根因」。
  2. 软熔断(soft halt):禁止开新仓,只允许减仓。已挂的开仓单全部撤,已挂的平仓单保留。仓位继续被市场打,但不会再增加敞口。
  3. 硬熔断(hard halt):撤所有挂单,停止策略进程的下单权限,OMS 层把该策略的 order channel 设成只读。仓位不动,等人决策。
  4. 强制平仓(forced unwind):用 TWAP 或 VWAP 把仓位切片减到零。这一档只能由人触发,自动化系统不要自己跳到这一档——因为强平动作本身可能在窄市里加剧亏损,需要交易员判断市况。

每一档都要明确:触发条件、动作清单、谁能解锁、解锁需要什么证据。常见错误是「触发条件写了三页、动作写了一段、解锁条件没写」,结果熔断后没人敢解锁,整个策略停一周。

四点三、熔断的状态机

把熔断写成一个有限状态机会让代码和文档对得上:

状态:NORMAL → THROTTLED → SOFT_HALT → HARD_HALT → UNWINDING → FROZEN

转移:
  NORMAL      → THROTTLED   : reject_rate > 1%
  NORMAL      → SOFT_HALT   : intraday_dd > 1%
  THROTTLED   → SOFT_HALT   : reject_rate > 5% OR persisted > 60s
  SOFT_HALT   → HARD_HALT   : intraday_dd > 2%
  HARD_HALT   → UNWINDING   : 人工触发 + 双人复核
  UNWINDING   → FROZEN      : 仓位 = 0
  *           → HARD_HALT   : 任意 P0 事件
  HARD_HALT   → NORMAL      : 人工触发 + 双人复核 + RCA 已写

注意几个细节:

四点四、降级而不是停服

「熔断」在量化语境里不一定是停服。一些场景下更合适的是降级:

降级路径都要在系统里 first-class,不是「等出事现写」。事先没有写过、没有演练过的降级路径在事故现场永远跑不通——这是从软件领域的混沌工程到金融系统压力测试都反复证明的事。


五、风控前置

熔断处理的是已经发生的异常,风控要把异常拦在它发生之前。一个成熟的量化系统里,风控不是一个进程,而是分布在三个时间点的三组检查:交易前(pre-trade)、交易中(in-trade)、交易后(post-trade)。

五点一、Pre-trade 风控

每一笔订单在离开本机之前必须过一次 pre-trade 检查。延迟预算很紧——HFT 场景下常常只有几微秒——但检查本身不能省。最常见的项:

这一组检查必须放在 OMS 出口、靠近交易所网关的位置,不能放在策略进程里——策略进程里的检查可以被绕过,靠近网关的检查是唯一守得住底的位置。SEC Rule 15c3-5(详见第六节)把这一类检查列为 broker-dealer 的强制义务。

五点二、In-trade 风控

订单进入交易所之后到成交之前,仍然要持续监控:

五点三、Post-trade 风控

成交回报到达后,立即(毫秒级)更新:

每一笔成交都要落入一个事件流,下游的清算、风险、合规模块都从这个事件流读,不要让每个模块独立从交易所读——一旦事件流是单源的,对账就只用对一处。

五点四、风控的版本与上线

风控规则和策略代码一样要走变更评审。具体两条强制规定:

这些约束乍看繁琐,但是少了它们之后,事故现场会一遍遍重演。

五点五、风控的阈值管理

一个容易被低估的风险源是「阈值在某个地方写死了」。预测一些场景:

解决办法是所有阈值都从配置中心读,不要 hardcode。配置中心的每一次改动都要:

  1. 记录 who / what / when / why / approver
  2. 改动前先在 shadow/staging 环境验证这个新阈值不会导致所有单都被拒
  3. 改动后定时审计(例如周一上午)所有阈值是否仍然合理
  4. 保留历史版本,能快速回滚

大多数风险事件都不是一次大意引起的,而是多次小改动积累起来的。


六、监管报送

把视角从内部运维切到外部监管。各国监管机构对程序化 / 算法 / 高频交易都有报送和合规要求,下面挑三个有代表性的辖区做工程口径的概览,重点是「要报什么字段」和「为什么这条要求会影响系统设计」。具体条文要以最新版法规为准,本节不替代合规咨询。

六点一、A 股程序化交易报告

中国证监会与上交所、深交所、北交所于 2024 年陆续发布了关于程序化交易的管理规定(《证券市场程序化交易管理规定(试行)》及配套交易所细则,2024 年 5 月公布、2024 年 10 月起施行)。要点包括:

工程口径下,这套规则推导出来的系统要求至少包括:

六点二、美国 SEC Rule 15c3-5

SEC Rule 15c3-5(市场准入规则,Market Access Rule,2010 年通过、2011 年生效)要求向市场提供准入的 broker-dealer 必须建立合理设计的风险管理控制和监督程序,以管理与市场准入相关的财务、监管和其他风险。关键要求:

工程口径下:

六点三、欧洲 MiFID II / RTS 6

欧盟 MiFID II(2018 年生效)下,从事算法交易的投资公司要遵守若干技术性标准(Regulatory Technical Standards),其中 RTS 6 专门处理算法交易系统的组织要求。要点包括:

工程口径下:

六点四、共通的工程结论

把三个辖区的要求合在一起看,和量化系统正常工程实践重合的部分非常多。共通的结论:

下一节用四个真实事故把这些设施的代价讲清楚。


七、事故复盘

事故是这套设施的实战考核。下面四个事件不是要列罪,而是看每一个里面到底是哪一层设施失守,以此对前几节的内容做反向校验。每个事件的事实部分来自公开资料,因果分析是工程视角下的简化,不构成对相关人员的法律评价。

七点一、Knight Capital,2012 年 8 月 1 日

事件经过:纽约证交所当天上线 Retail Liquidity Program(RLP)。Knight Capital 部署了配套的 SMARS 路由代码,运维过程中没有把一个名为 Power Peg 的旧测试代码从一台老服务器上下线。一个被复用的二进制 flag 在新代码里启用了 RLP,但在那台没更新的老服务器上启用了 Power Peg。开盘起 45 分钟内,Power Peg 在生产里以指数级速率发出错误订单,公司损失约 4.6 亿美元,几天内被收购退出。

工程视角的失守层次:

这一个事件几乎把本文前六节里的每一项设施都打了一个反例。

七点二、Long-Term Capital Management,1998 年

LTCM 不是程序化交易事故,但它对量化运维的启示同样直接:高杠杆 + 模型假设失效 + 流动性枯竭 = 任何事后熔断都来不及。1998 年俄罗斯债务违约导致全球套利价差极端走宽,LTCM 的相对价值套利组合在几周内损失约 46 亿美元,最终由美联储召集大行救助。

工程视角的失守层次:

LTCM 的教训不能用「加几条监控」修复,它必须落到第八节的制度文化层面:杠杆审批、敞口上限、退出路径必须在系统设计的最初就锁死。

七点三、Three Arrows Capital,2022 年

3AC 是加密货币基金,2022 年 6 月因 Luna / UST 崩盘和 stETH 折价被多家交易所连环 margin call,最终破产清算。和 LTCM 类似,工程视角下的核心问题是:

对自营 / 私募的工程结论:多个账户、多个交易所、多个借贷渠道下的总敞口必须有一个汇总视图,这个视图必须实时;只看单边是另一种形式的盲飞。

七点四、FTX,2022 年

FTX 在 2022 年 11 月暴雷,相关诉讼文件(包括 SBF 案的起诉书及 Chapter 11 申请文件)显示了多种公司治理与内部控制缺失。工程视角下与本文相关的几条:

这一个事件把本文从工程话题推到了治理层。技术再到位,如果制度允许操作员单方面改风控参数、绕开 pre-trade 检查、关闭审计日志,再多监控也救不回来。第八节展开这部分。

七点五、小结:反复的故事

从 Knight Capital 到 FTX,故事在 10-20 多年间反复重演:

  1. 变更管理薄弱:Knight 是漏部署,FTX 是无审计修改。
  2. 风控参数容易被绕开:Knight 的 pre-trade 缺失,FTX 的负余额允许。
  3. 跨系统的数据对账不一致:3AC 的多交易所敞口没有汇总,FTX 的客户与自营混淆。
  4. 极端情况没有预案:LTCM 和 3AC 都是流动性突然枯竭,事前 VaR 模型没覆盖。

这些失误一次次给出的启示总是一样的:工程和治理必须绑在一起。今天的工程最佳实践(灰度、对账、审计、自动化)都可以追溯到历史教训。反过来说,如果一个系统没有这些实践的痕迹,那它就是在等待一个历史重演的机会。


八、制度与文化

前面七节是技术。这一节是技术的边界条件。任何监控、熔断、风控、报送,最终都跑在一个由人组成的系统里;制度和文化决定了人会不会去维护这些东西、会不会在事故里诚实面对、会不会在压力下保持纪律。

八点一、变更评审

任何会进入生产路径的修改都要走变更评审:策略代码、风控参数、交易所连接配置、报送字段、监控阈值。评审的最低形态是 GitHub Pull Request 的 review;更严格的形态会在评审之外加一层 Change Advisory Board(CAB)周会。CAB 的目标不是让所有人投票通过每一行代码,而是把「这次变更的风险面」「回滚方案」「上线窗口」当面对齐。

实践细节:

八点二、混沌工程

普通互联网公司里的 Chaos Monkey 在量化里有一个直接对应物:故意触发熔断、故意切换主备源、故意在影子环境里下错单,验证整套设施能否正常响应。具体做法:

混沌工程在金融系统里推行最大的阻力不是技术,是「在生产上故意制造故障」这件事和合规、风险偏好的天然冲突。要解决这个冲突,要么在仿真环境里跑,要么在交易日之外的低风险时段跑,要么用「影子流量」的方式跑——故障注入,但订单不真发。

八点三、灰度发布

策略 / 风控 / 模型的更新都不要直接全量。常见的灰度形态:

灰度的关键是能比较什么、怎么比较。如果新老版本不在同一时段、同一行情下跑,比较结果就没意义;这又反过来要求基础设施支持「影子模式」这种 first-class 的运行方式。

八点四、双人复核

任何风险动作都要双人复核(two-person rule,亦称 4-eye principle):

双人复核不是「让两个人都点确认」这么形式化的事,它的本质是在每一个能造成大额损失的动作上插入一道独立判断。这道独立判断要满足两个条件:第二个人有能力判断、第二个人没有动机和第一个人合谋。技术上要确保两人的认证是独立的,制度上要确保审批人不是发起人的下属。

八点五、复盘文化

事故复盘有两种文化,差距是数量级的:

前者写出来的复盘报告每篇都长得一样,后者每篇都不一样。判断一家机构属于哪一种文化,最快的办法就是翻它最近三次复盘报告:是不是每一次的 action item 都不一样、是不是有上一次 action item 的 follow-up、是不是 follow-up 闭环了。


九、SLO 与 error budget 计算示例

把上面这些设施的预算定量地拉一拉,就到了 SLO 与 error budget。SRE 的这套语言搬到量化系统里要换两个变量:

举一个具体例子。假设一个 A 股日内策略,交易时段每天 4 小时,月交易日数 22。给各 SLI 设 SLO:

每一项 error budget 的「允许停摆秒数」直接折算为「这段时间策略要么停、要么裸跑无风控」。把这两条进一步翻译成 PnL 影响:

下面给出一个最小的 error budget 计算脚本。tracker 维护当月已用预算,越线时返回 False,调用方据此触发熔断或降级:

from dataclasses import dataclass, field
from datetime import datetime, timedelta

@dataclass
class SLO:
    name: str
    target: float                # 例如 0.9999
    window: timedelta            # 例如 timedelta(days=30)
    total_seconds: float         # 该窗口总服务秒数(仅交易时段)

    @property
    def budget_seconds(self) -> float:
        return self.total_seconds * (1.0 - self.target)


@dataclass
class BudgetTracker:
    slo: SLO
    consumed_seconds: float = 0.0
    incidents: list = field(default_factory=list)

    def consume(self, seconds: float, reason: str, ts: datetime) -> bool:
        self.consumed_seconds += seconds
        self.incidents.append((ts, seconds, reason))
        return self.consumed_seconds <= self.slo.budget_seconds

    def remaining(self) -> float:
        return max(0.0, self.slo.budget_seconds - self.consumed_seconds)


# 示例:A 股日内策略,每月 22 个交易日,每天 4 小时交易时段
total = 22 * 4 * 3600
feed_slo = SLO("feed", 0.9999, timedelta(days=30), total)
oms_slo  = SLO("oms",  0.9995, timedelta(days=30), total)
risk_slo = SLO("risk", 0.9999, timedelta(days=30), total)

print(f"feed budget:  {feed_slo.budget_seconds:.1f} s")
print(f"oms  budget:  {oms_slo.budget_seconds:.1f} s")
print(f"risk budget:  {risk_slo.budget_seconds:.1f} s")

这段代码不是生产实现,是一个把 SLO 翻译成秒级预算的工具骨架。在生产里 BudgetTracker 应当从持久化层(数据库或 KV)读写 consumed_secondsincidents 表要落库以备复盘和合规审计。

把 error budget 用起来的关键是「越线时怎么办」。一个常见做法:本月预算用尽即冻结所有非紧急变更,直到下月预算重置;这给出了「稳定性」与「迭代速度」之间的硬约束。


十、实战:实时风控守护

把前面几节的内容落到一份能跑的代码。下面这份 risk_guard.py 实现了三件事:

  1. 订单速率限制:基于 token bucket 的全局与单标的双层限速。
  2. 回撤监控:基于 mark-to-market PnL 维护当日高水位与回撤。
  3. 熔断状态机:实现第四节的状态转移,越线时把策略推到对应状态。

这份代码不依赖具体 broker / OMS,使用 asyncio.Queue 模拟订单流,使用 prometheus_client 暴露指标。实际接入 OMS 时把 submit() 替换成真实的 OMS 客户端调用即可。

"""risk_guard.py — 量化交易实时风控守护进程(教学版本)"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional

from prometheus_client import Counter, Gauge, start_http_server

log = logging.getLogger("risk_guard")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


class State(Enum):
    NORMAL = "NORMAL"
    THROTTLED = "THROTTLED"
    SOFT_HALT = "SOFT_HALT"
    HARD_HALT = "HARD_HALT"
    UNWINDING = "UNWINDING"
    FROZEN = "FROZEN"


# Prometheus 指标
ORDERS_SUBMITTED = Counter("rg_orders_submitted_total", "Orders accepted by guard", ["symbol"])
ORDERS_REJECTED = Counter("rg_orders_rejected_total", "Orders rejected", ["reason"])
PNL_GAUGE = Gauge("rg_pnl", "Current mark-to-market PnL")
DRAWDOWN_GAUGE = Gauge("rg_drawdown", "Current intraday drawdown ratio")
STATE_GAUGE = Gauge("rg_state", "Current state (numeric encoded)")
TOKEN_GAUGE = Gauge("rg_tokens", "Current tokens in global bucket")


@dataclass
class TokenBucket:
    """全局/单标的的 token bucket 限速器。"""
    capacity: float
    refill_per_sec: float
    tokens: float = 0.0
    last_refill: float = field(default_factory=time.monotonic)

    def __post_init__(self):
        self.tokens = self.capacity

    def take(self, n: float = 1.0) -> bool:
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_sec)
        self.last_refill = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False


@dataclass
class DrawdownMonitor:
    """日内回撤监控:维护当日高水位与最大回撤。"""
    high_watermark: float = 0.0
    current_pnl: float = 0.0

    def update(self, pnl: float) -> float:
        self.current_pnl = pnl
        if pnl > self.high_watermark:
            self.high_watermark = pnl
        if self.high_watermark <= 0:
            return 0.0
        return (self.high_watermark - pnl) / max(1.0, abs(self.high_watermark))


@dataclass
class RiskConfig:
    global_rate_per_sec: float = 50.0          # 全局每秒下单上限
    global_burst: float = 100.0                # 全局突发桶容量
    symbol_rate_per_sec: float = 10.0          # 单标的每秒下单上限
    symbol_burst: float = 20.0
    throttle_dd: float = 0.005                 # 0.5% 触发限速
    soft_halt_dd: float = 0.01                 # 1.0% 触发软熔断
    hard_halt_dd: float = 0.02                 # 2.0% 触发硬熔断
    reject_burst_window_sec: float = 5.0       # 拒单 burst 窗口
    reject_burst_threshold: int = 100          # 拒单 burst 阈值


@dataclass
class Order:
    symbol: str
    side: str           # "BUY" | "SELL"
    qty: float
    price: float
    is_close: bool = False


class RiskGuard:
    def __init__(self, cfg: RiskConfig):
        self.cfg = cfg
        self.state = State.NORMAL
        self.global_bucket = TokenBucket(cfg.global_burst, cfg.global_rate_per_sec)
        self.symbol_buckets: dict[str, TokenBucket] = {}
        self.dd_monitor = DrawdownMonitor()
        self.recent_rejects: list[float] = []
        self._lock = asyncio.Lock()

    def _bucket_for(self, symbol: str) -> TokenBucket:
        if symbol not in self.symbol_buckets:
            self.symbol_buckets[symbol] = TokenBucket(
                self.cfg.symbol_burst, self.cfg.symbol_rate_per_sec
            )
        return self.symbol_buckets[symbol]

    def _record_reject(self, reason: str) -> None:
        ORDERS_REJECTED.labels(reason=reason).inc()
        now = time.monotonic()
        self.recent_rejects.append(now)
        cutoff = now - self.cfg.reject_burst_window_sec
        self.recent_rejects = [t for t in self.recent_rejects if t >= cutoff]
        if len(self.recent_rejects) >= self.cfg.reject_burst_threshold:
            log.error("reject burst detected: %d in %.1fs -> HARD_HALT",
                      len(self.recent_rejects), self.cfg.reject_burst_window_sec)
            self._transition(State.HARD_HALT)

    def _transition(self, new_state: State) -> None:
        order = [State.NORMAL, State.THROTTLED, State.SOFT_HALT,
                 State.HARD_HALT, State.UNWINDING, State.FROZEN]
        if order.index(new_state) >= order.index(self.state):
            if new_state != self.state:
                log.warning("state transition: %s -> %s", self.state.value, new_state.value)
                self.state = new_state
        STATE_GAUGE.set(order.index(self.state))

    async def on_pnl_update(self, pnl: float) -> None:
        async with self._lock:
            dd = self.dd_monitor.update(pnl)
            PNL_GAUGE.set(pnl)
            DRAWDOWN_GAUGE.set(dd)
            if dd >= self.cfg.hard_halt_dd:
                self._transition(State.HARD_HALT)
            elif dd >= self.cfg.soft_halt_dd:
                self._transition(State.SOFT_HALT)
            elif dd >= self.cfg.throttle_dd:
                self._transition(State.THROTTLED)

    async def check(self, order: Order) -> bool:
        async with self._lock:
            TOKEN_GAUGE.set(self.global_bucket.tokens)
            if self.state in (State.HARD_HALT, State.UNWINDING, State.FROZEN):
                self._record_reject("halted")
                return False
            if self.state == State.SOFT_HALT and not order.is_close:
                self._record_reject("soft_halt_open")
                return False
            if not self.global_bucket.take(1):
                self._record_reject("global_rate")
                return False
            if not self._bucket_for(order.symbol).take(1):
                self._record_reject("symbol_rate")
                return False
            ORDERS_SUBMITTED.labels(symbol=order.symbol).inc()
            return True


async def fake_oms(guard: RiskGuard, orders: asyncio.Queue) -> None:
    """伪 OMS:从队列取订单,过 guard,模拟成交并更新 PnL。"""
    pnl = 0.0
    while True:
        order: Order = await orders.get()
        ok = await guard.check(order)
        if not ok:
            continue
        # 简化:买入按价 +1 滑点成交,卖出按价 -1 滑点成交,
        # PnL 抖动用一个简单噪声项模拟,触发回撤。
        import random
        slip = 0.01 * (1 if order.side == "BUY" else -1)
        fill = order.price + slip
        pnl_delta = -abs(order.qty) * abs(slip) + random.gauss(0, 5)
        pnl += pnl_delta
        await guard.on_pnl_update(pnl)


async def order_generator(orders: asyncio.Queue) -> None:
    """生成示例订单流。"""
    import random
    symbols = ["600000.SH", "000001.SZ", "510300.SH"]
    while True:
        await asyncio.sleep(0.02)  # 50 笔/秒
        await orders.put(Order(
            symbol=random.choice(symbols),
            side=random.choice(["BUY", "SELL"]),
            qty=100,
            price=10.0 + random.random(),
            is_close=random.random() < 0.3,
        ))


async def main():
    start_http_server(8000)
    cfg = RiskConfig()
    guard = RiskGuard(cfg)
    orders: asyncio.Queue = asyncio.Queue(maxsize=1000)
    await asyncio.gather(
        order_generator(orders),
        fake_oms(guard, orders),
    )


if __name__ == "__main__":
    asyncio.run(main())

把它跑起来:

$ python risk_guard.py
$ curl -s localhost:8000/metrics | grep -E '^rg_'

可以看到 rg_orders_submitted_totalrg_orders_rejected_totalrg_drawdownrg_staterg_tokens 都按预期更新。把 cfg.global_rate_per_sec 调到比订单生成器低,可以观察到 rg_orders_rejected_total{reason="global_rate"} 持续增长;把 cfg.hard_halt_dd 调小(例如 0.001),等到 PnL 抖到这一档时可以看到 rg_state 跳到 HARD_HALT 对应的数值,并且后续所有订单都被拒(reason=“halted”)。

要把它推到生产,至少补这几件事:


十一、生产交易系统的应急响应流程

当系统告警触发、特别是 Level 4 / 5 告警时,响应的速度和决策质量直接影响亏损规模。一套成熟的应急流程应该包括这几个环节。

十一点一、秒级的自动止损

一旦触发硬止损(例如账户净值跌破限额),系统应该能在 100 毫秒内完成清仓,不等人工介入。这要求:

十一点二、 5 分钟的人工审核与恢复

Level 5 触发后,系统应该:

  1. 所有新订单自动拒绝(不是取消现有单,而是在源头就拒掉新下单请求)。
  2. 5 分钟内,风控负责人要在 ChatOps / 电话会议里向交易总监报告:
    • 触发原因(是单个策略还是全体账户?)
    • 当前账户状态(已清仓的持仓、剩余冻结资金)
    • 推荐恢复路径(完全重启、部分重启、换策略参数)
  3. 在总监批准前,系统保持 HARD_HALT,不允许任何人工「逐笔批准」突破这个硬止损。

多数风险就是源于”总监说恢复”和”实际代码判定恢复”之间的延迟与不对齐。最安全的做法是恢复也需要人工命令,风控守护进程收到 CLI 或 ChatOps 命令后再改状态,而不是让系统自动判定。

十一点三、事后 48 小时的完整复盘

触发 Level 4 / 5 后必须启动复盘流程,必须包括这几块:

每一个复盘点都要对应到具体的改进项,而且要定明确的完成时间。光写在报告里不改进,下次同样的问题还会再犯。


十二、组织与文化

从微观的代码、告警,到宏观的组织结构,一个交易系统的稳定性最后都取决于人。这一节讨论几个容易被忽视的组织因素。

十二点一、风控的权力结构

在多数初创或小型量化机构里,风控是被夹在中间的:上面要听投资总监的,下面要听研究/交易团队的,结果一旦有冲突就陷入权力真空。最危险的状态就是”风控经理发现风险,找总监汇报,总监说继续跑,结果亏大了”。

正确的结构是这样的:

十二点二、培训与 on-call rotation

新交易员、新策略研究员入职后的前 3 个月,必须跟一个资深人员 pair program,包括:

on-call 轮值要透明公开,这样才能防止某个人被过度依赖。一个好的 on-call 制度应该让新人也能在辅导下独立处理大部分 Level 3 / 4 告警。

十二点三、文化:失败复盘而不是甩锅

量化交易里出现 bug 或风险事件是完全正常的,关键是失败后怎么对待

一个交易系统最后能稳定到什么程度,最终是由这个团队的文化决定的。再完美的工程也抵不了一个「出了问题就甩锅」的文化。


十三、系统化的告警与升级

前面各层的监控数据最终要汇总成一个一致的动作框架。多个指标发生波动时,系统需要按优先级升级告警,避免告警风暴。

十三点一、告警升级路径

典型的升级流程是这样的:

Level 1 (Info)   : 新策略上线
Level 2 (Warn)   : 单个因子 IC 衰减超 20%
Level 3 (Alert)  : 因子衰减 + PnL 环比下降 > 10%
Level 4 (Alarm)  : Level 3 持续 > 30 分钟,或 PnL 回撤 > 2% 日均
Level 5 (Critical) : 账户余额跌破风控硬止损线
-> 触发 Level 5 : 自动降仓 50%,ChatOps 通知全体

每一级的升级都应该有明确的数据驱动触发条件,不靠人工判断。而且告警抑制(suppression)必须显式编码,避免级联。例如如果 Level 3 已经触发,就不再重复发 Level 2 的告警。

十三点二、白天 / 夜间 / 假日的分层值班

不同时段的值班强度应该不同:

这套分级需要跟交易日历、交易时段、策略本身的决策周期绑定。没有一个通用的「告警优先级」标准能适配所有场景。

十三点三、无意义的告警的代价

一个经常被忽略的工程成本:频繁误报会导致值班人员「告警疲劳」。研究表明,在连续 100 条告警中,如果有 20 条以上是虚警,人对真警告的反应时间会延长 2-3 倍。换成交易系统的语言,这意味着真正的止损机会被错过了。

所以告警系统的设计哲学应该是”宁可漏报,不要误报”。每一个告警都应该是「人工看到这个指标后会采取行动」的条件,而不是「这个指标理论上可能和风险相关」。前者会让值班人员持续保持警觉,后者会让他们点完一百条告警后直接关掉手机。

十三点四、告警指标的长期演变

一个通常被低估的问题是告警规则的老化。策略上线时的告警阈值在 6 个月后可能就不适用了——因为策略的波动率、滑点、下单速率都会随着市场变化而变化。要避免「告警狼来了」,需要定期审计:


十四、回到本文的核心问题

回到开头:把交易系统从「能跑」升级到「能托管真金白银」需要什么?答案是这一篇里的全部十四个层次,缺一不可:

  1. 监控分层让任何异常都能从 PnL 一直追到基础设施。
  2. 告警值班把信号转成行动,并保证有人 24×7 在键盘前。
  3. 熔断降级让自动化系统在 99% 的事故里不依赖人就能止损。
  4. 风控前置让大部分异常根本到不了交易所。
  5. 监管报送把内部秩序对齐到外部规则,避免「合规债务」累积。
  6. 事故复盘把每一次失守变成下一次的防线。
  7. 制度文化让上面六层不会因为一次时间紧、一次老板要、一次「就这一次」而被绕开。
  8. 变更管理、混沌工程、灰度发布保证系统在升级中保持稳定。
  9. 双人复核与复盘文化把个人的失误转变为系统的改进。
  10. SLO 与 error budget把稳定性需求定量化、可操作化。
  11. 实战风控守护用代码实现设施,避免纯口头约定。
  12. 组织结构、权力分配、培训体系让人能保持警觉。
  13. 告警治理与长期演变保证通知信息的信噪比。

少了任何一层,账户被打穿之前都不会有人察觉;而打穿是即时清算的,没有第二次机会。这是量化交易工程区别于普通后端工程的根本所在。

每一层都有成本——计算成本、延迟开销、人力投入。新团队常常从「这套设施能省吗」开始,结论往往是「省不了」。一个真实的对标数据:Knight Capital 事故的 4.6 亿美元,换算成「可以买多少套完美的量化基建」,答案是数千套。任何合理的风险成本控制都会得到一个结论:投入到这套设施里的钱,永远是最划算的投资

更细的投资回报分析可以这样看:

这些成本不是消耗,都是风险的转移成本。管理风险的钱再多都不会亏。

本系列从第一篇的「全景」讲到这一篇的「运维」,覆盖的是从 alpha 研究、数据底盘、因子、策略、组合、回测、执行、到生产运维的全链路。每一篇都是完整系统的一个层次切面。

在实际部署中,这些层次之间存在着微妙的权衡。例如:

推荐的阅读方式不是从头到尾按顺序,而是按照自己的角色需求组合:

最后一句话:量化交易工程没有捷径,只有扎实。那些在这个领域活下来的团队,都不是因为算法独一无二或者本金特别充裕,而是因为他们对工程纪律、风险管理、回测方法论、操作规范的执行力一直保持在极高的水平。新进的团队往往会高估自己的 alpha,低估运维的成本;反复交学费之后才明白,稳定性这个课题值得投入相当的精力。

一个衡量标准:如果一个量化机构用在「风险管理 + 系统运维」上的工程资源不足总工程资源的 30%,那多半还在温水里。真正成熟的机构,这个比例通常在 40-50%,有的甚至达到 60%。这不是浪费,这是活下来的代价。选择哪一个弱化,等价于赌这一个层次的风险永远不会发生——在真实市场的长尾风险面前,这个赌局的概率永远对赌客不利。

本文涵盖的十四个主题(监控、告警、熔断、风控、合规、复盘、制度、变更、灰度、双人复核、SLO、实战代码、组织、告警演变)都不是 option,都是 must-have。不是因为「最佳实践」这个词很洋气,而是因为没有它们的系统,概率上会在某个时刻被打穿。而被打穿的代价——资本金清零、监管处罚、信誉毁灭——是任何「节省工程成本」都补不回来的。

从 2010 年的 Knight Capital 事故到 2022 年的 FTX 暴雷,十多年间我们一次次看到了相同的根本原因反复出现。这些教训不是历史,是一个行业的试错过程,每一个新进的团队都可以从中学习而不必重复。你今天在系统里多花一天时间做 pre-trade 风控检查,明天可能就不会经历一场 4.6 亿美元的事故。

这一篇到此收尾。本系列最后还有一篇总结,会回到最初的问题:一个工程师在不同阶段应该如何分配精力?从研究期的「快速迭代」到交易期的「稳定至上」,再到风险管理的「永不停歇的对抗」,每个阶段的游戏规则都完全不同。希望这 28 篇能给进入这个领域的人一张完整的地图。

本文是【量化交易】系列的第 28 篇,也是工程架构部分的收尾。从这一篇开始,后续内容会转向总结与规划:如何根据人生不同阶段的目标和资源约束,制定一份量化交易的学习与职业规划。感谢你读到这里。

更新日志:本文最后一次更新于 2024 年底,涵盖的规范和最佳实践基于当时的最新合规要求和行业实践。随着市场结构和监管环境的变化,某些具体数字和阈值可能需要调整,但整体框架应该能保持稳定 3-5 年。

提示:本系列所有文章中涉及的代码示例都基于 Python / 伪代码呈现,旨在表达逻辑而非可直接部署的生产代码。任何真实部署都需要根据具体交易所、清算商、资金方的系统要求做深度适配。特别提醒:量化交易涉及真实资金风险,任何部署前必须做充分的回测、仿真演练、小额验证。本系列不构成投资建议,仅供工程参考。


附录:关键术语速查表

本系列全文用到的某些术语的快速参考:


参考与延伸阅读


导航:上一篇 交易系统架构系列首页

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。

2026-05-01 · quant

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。


By .