风控是量化系统里唯一「不产生收益但决定生死」的模块。它不创造 alpha,但一次失效可能让一年的收益归零。2012 年骑士资本(Knight Capital)因为部署失误在 45 分钟内亏损 4.4 亿美元,直接导致公司被收购——根因就是缺少一个能在异常报单量下自动熔断的风控。
真正难的地方在于风控必须在关键路径上、但又不能拖慢关键路径。每一笔订单都要过风控,所以风控的延迟直接加到端到端延迟上;但风控规则又要足够复杂才能拦住异常。这个矛盾决定了风控的实现方式:规则预编译 + 状态预加载 + 无锁读取。
本文按「层次 → 限额 → 计算模型 → 校验 → 异常防范 → 熔断 → 规则引擎 → 集成 → 监控」的顺序展开。订单侧的状态管理在 订单管理与执行算法 中讨论,本文聚焦风控逻辑。
目录
- 风控的三个层次
- 限额体系设计
- 实时风控的计算模型
- 资金与持仓校验
- 自成交与异常报单防范
- 熔断与降级
- 风控规则引擎
- 与 OMS 的集成点
- 风控监控与告警
1. 风控的三个层次
风控按介入时机分三层,每层的定位完全不同:
| 层次 | 时机 | 目标 | 延迟预算 |
|---|---|---|---|
| 事前 | 报单前 | 拦截不该发的单 | < 5 μs |
| 事中 | 持仓中 | 限制风险敞口 | 毫秒级 |
| 事后 | 收盘后 | 复盘与归因 | 分钟级 |
策略信号 ──► [事前风控] ──► OMS ──► 交易所
│
├─ 资金/持仓限额
├─ 单笔/单日限额
├─ 价格偏离校验
└─ 自成交防范
持仓变化 ──► [事中风控] ──► 告警/强制平仓
├─ 实时敞口监控
├─ 回撤监控
└─ 集中度监控
收盘 ──► [事后风控] ──► 报告
├─ 交易行为分析
├─ 限额使用率
└─ 异常交易识别
事前风控必须在关键路径上,因为它是唯一能真正阻止损失的环节。事后风控只能发现问题,无法挽回。很多团队把重心放在事后报表上,这是本末倒置。
2. 限额体系设计
限额是多维度的,每个维度都有「软限」和「硬限」两档:
| 维度 | 软限(告警) | 硬限(拦截) | 粒度 |
|---|---|---|---|
| 单笔金额 | 50 万 | 100 万 | 每笔 |
| 单标的持仓 | 流通股 3% | 5% | 每标的 |
| 单日成交量 | 日均量 10% | 20% | 每日 |
| 总持仓市值 | 资金 80% | 95% | 全局 |
| 单日亏损 | 2% | 5% | 每日 |
| 换手率 | 200% | 300% | 每日 |
LIMITS = {
'max_order_amount': {'soft': 5e5, 'hard': 1e6}, # 单笔金额
'max_symbol_position': {'soft': 0.03, 'hard': 0.05}, # 单标的占流通股比例
'max_daily_volume': {'soft': 0.10, 'hard': 0.20}, # 单日成交量占市场比例
'max_gross_exposure': {'soft': 0.80, 'hard': 0.95}, # 总仓位占资金比例
'max_daily_loss': {'soft': 0.02, 'hard': 0.05}, # 单日亏损比例
'max_turnover': {'soft': 2.0, 'hard': 3.0}, # 单日换手率
}
软限触发告警,硬限直接拒单。两档设计的价值在于:软限让交易员有时间反应(降低仓位),硬限防止灾难。只有硬限会导致频繁的意外拒单,只有软限则可能来不及反应。
限额不是静态的,要随策略表现动态调整:
def dynamic_limit(base_limit, strategy_sharpe, days_live): # 表现差时收紧限额
if days_live < 20:
return base_limit * 0.3 # 新策略只用 30% 限额
if strategy_sharpe < 0:
return base_limit * 0.5 # 表现差时减半
return base_limit
3. 实时风控的计算模型
风控要在微秒级完成校验,所以不能有锁、不能有分配、不能有系统调用。核心是「状态常驻内存 + 无锁读取」:
// 风控状态:所有计数器的原子快照
struct RiskState {
alignas(64) std::atomic<int64_t> gross_exposure{0}; // 总敞口(分)
alignas(64) std::atomic<int64_t> daily_pnl{0}; // 当日盈亏(分)
alignas(64) std::atomic<int64_t> daily_volume{0}; // 当日成交量
alignas(64) std::atomic<int64_t> order_count{0}; // 当日报单数
alignas(64) std::atomic<int64_t> reject_count{0}; // 当日拒单数
};
// 校验:只读原子变量,无锁
bool RiskEngine::check(const Order& o) {
int64_t new_exposure = state_.gross_exposure.load(std::memory_order_relaxed)
+ o.price * o.qty;
if (new_exposure > limits_.max_gross_exposure) {
state_.reject_count.fetch_add(1, std::memory_order_relaxed);
return false;
}
if (o.price * o.qty > limits_.max_order_amount) return false;
return true;
}
关键设计是读写分离:校验路径只读原子变量(无锁、无竞争),状态更新由成交回报驱动(低频)。这样校验路径的延迟稳定在百纳秒级。
限额的原子性问题:多笔订单并发校验时,可能同时通过导致总量超限。解法是在校验时预占(reserve):
bool RiskEngine::check_and_reserve(const Order& o) {
int64_t amount = o.price * o.qty;
int64_t cur = state_.gross_exposure.load(std::memory_order_relaxed);
while (true) {
if (cur + amount > limits_.max_gross_exposure) return false;
// CAS 预占,失败则重试
if (state_.gross_exposure.compare_exchange_weak(
cur, cur + amount, std::memory_order_relaxed)) {
return true;
}
}
}
成交或撤单后释放预占的额度。这个模式与 分布式幂等与可靠性 中的额度扣减是同一个问题。
4. 资金与持仓校验
资金校验的复杂性在于可用量与总量的区分:
总资金 = 可用资金 + 冻结资金 + 持仓市值
可用资金 = 总资金 − 冻结(挂单占用)− 已用保证金
期货还有保证金制度,占用随价格波动:
def available_cash(account, order):
if order.is_futures:
margin = order.price * order.qty * order.multiplier * margin_rate # 保证金
return account.cash - account.frozen - margin # 盘中用最新价估算占用
else:
return account.cash - account.frozen - order.price * order.qty # 股票全额
冻结/解冻的时序是资金对不上的常见原因:
报单 → 冻结资金 → 部分成交 → 按成交量解冻 → 撤单 → 解冻剩余
任何一个环节漏掉都会导致资金「凭空消失」。必须以柜台回报为准,本地计算只作预估。
持仓校验还要考虑 T+1 制度:当天买入的股票当天不能卖,所以「可用持仓」不等于「总持仓」:
def available_position(account, symbol):
pos = account.positions[symbol]
return pos.yesterday_qty - pos.frozen_sell_qty # 可用 = 昨仓 − 卖出冻结
5. 自成交与异常报单防范
自成交(wash trade) 是监管红线:同一账户或关联账户的买卖单成交,构成「洗售」。事前防范的方法是在报单前检查:
def check_self_trade(order, open_orders):
for o in open_orders:
if o.symbol != order.symbol:
continue
if o.account == order.account and o.side != order.side:
if can_cross(o, order): # 同账户反向挂单,可能自成交
return False
return True
异常报单的常见模式:
| 模式 | 特征 | 监管关注 |
|---|---|---|
| 幌骗(Spoofing) | 大量挂单后迅速撤单 | 是 |
| 塞单(Quote Stuffing) | 超高频率报撤单 | 是 |
| 分层(Layering) | 多价位挂单制造假象 | 是 |
| 尾随(Front Running) | 利用客户单信息抢先 | 是 |
| 频繁撤单 | 撤单率 > 90% | 是 |
撤单率是最重要的监控指标。交易所对高撤单率账户会重点关注,很多交易所对「报撤单比」有硬性要求(如不超过 100:1):
def check_cancel_ratio(state, limit=100):
if state.order_count == 0:
return True
ratio = state.cancel_count / state.order_count
return state.cancel_count <= state.order_count * limit
6. 熔断与降级
熔断是在检测到异常时自动停止交易。触发条件必须是明确可量化的:
class CircuitBreaker:
def __init__(self):
self.trips = []
def check(self, state):
if state.daily_pnl < -state.capital * 0.05: # 单日亏损超限
return self.trip('daily_loss')
if state.order_count > 100 and state.reject_count / state.order_count > 0.3:
return self.trip('high_reject_rate') # 拒单率异常
if state.orders_per_second > 1000:
return self.trip('order_rate') # 报单频率异常
if state.last_tick_age_ms > 5000:
return self.trip('stale_market_data') # 行情长时间无更新
return None
def trip(self, reason):
self.trips.append((time.time(), reason))
return reason
熔断后的降级策略要分级:
一级降级:停止新开仓,允许平仓(防止风险扩大)
二级降级:全部停止,撤销所有挂单
三级降级:断开通道,人工介入
降级必须能自动执行且可恢复。最常见的设计是「熔断自动、恢复手动」——熔断可以自动触发,但重新开始交易必须人工确认,避免在异常未排除时反复触发。
熔断的告警必须走独立的通道(短信、电话),不能依赖可能已经故障的交易系统,这与 告警设计与事故响应 中的原则一致。
7. 风控规则引擎
当风控规则增长到几十条时,硬编码会变得难以维护。规则引擎的价值是规则与代码分离:
rules:
- name: max_order_amount
priority: 10
condition: "order.price * order.qty > 1000000"
action: reject
message: "单笔金额超限"
- name: position_limit
priority: 20
condition: "position.ratio > 0.05"
action: reject
message: "单标的持仓超限"
- name: high_turnover
priority: 30
condition: "daily.turnover > 3.0"
action: reject
message: "换手率超限"
规则引擎的两种实现方式:
| 方式 | 实现 | 延迟 | 灵活性 |
|---|---|---|---|
| 解释执行 | 解析表达式树 | 微秒~毫秒 | 高 |
| 预编译 | 编译成函数指针数组 | 纳秒 | 中 |
| 位图/位运算 | 把规则编码成位 | 极低 | 低 |
关键路径上的风控应该用预编译:启动时把规则编译成有序的函数指针数组,运行时顺序调用,短路求值。
class CompiledRiskEngine {
std::vector<bool(*)(const Order&, const RiskState&)> checks_;
public:
void compile(const std::vector<Rule>& rules) {
for (auto& r : rules) {
checks_.push_back(compiler_.compile(r)); // 启动时编译
}
}
bool check(const Order& o) {
for (auto fn : checks_) {
if (!fn(o, state_)) return false; // 短路
}
return true;
}
};
8. 与 OMS 的集成点
风控与 OMS 的集成有三个位置,各有取舍:
位置 A:策略 → [风控] → OMS → 交易所 (风控在前,OMS 无感)
位置 B:策略 → OMS → [风控] → 交易所 (OMS 内部,紧密耦合)
位置 C:策略 → OMS → 交易所 → [旁路风控] (旁路,不阻塞)
| 位置 | 延迟影响 | 拦截能力 | 复杂度 |
|---|---|---|---|
| A | 中 | 强 | 低 |
| B | 低 | 强 | 中 |
| C | 无 | 弱(只能事后撤单) | 高 |
实践中的常见组合是 A + C:主风控在 OMS 之前(强拦截),旁路风控实时监控异常(补充检测)。旁路风控不阻塞交易,但能发现主风控规则未覆盖的模式。
风控的旁路不能用来拦截,只能告警和触发熔断。如果旁路风控能拦截,它就变成了关键路径的一部分,失去了「不阻塞」的意义。
9. 风控监控与告警
风控本身也需要被监控。核心指标:
| 指标 | 含义 | 阈值 |
|---|---|---|
| 拒单率 | 被拒订单/总订单 | > 10% 告警 |
| 限额使用率 | 当前/限额 | > 80% 告警 |
| 风控延迟 | 校验耗时 P99 | > 10 μs 告警 |
| 熔断次数 | 当日触发次数 | > 0 立即告警 |
| 撤单率 | 撤单/报单 | > 80% 告警 |
限额使用率是最有预警价值的指标。当某个限额用到 80% 时,说明策略的行为正在逼近边界,应该提前介入。这些指标需要纳入统一的监控面板,与交易系统的其他指标一起看。
风控日志必须完整留存,它是事后审计和监管检查的依据:
def log_risk_decision(order, decision, reason, latency_ns):
logger.info({
'ts': time.time_ns(),
'order_id': order.id,
'symbol': order.symbol,
'decision': decision, # PASS / REJECT
'reason': reason,
'latency_ns': latency_ns,
'limits_snapshot': current_limits(),
})
权衡取舍
| 维度 | 严格风控 | 宽松风控 |
|---|---|---|
| 安全性 | 高 | 低 |
| 误杀率 | 高 | 低 |
| 策略自由度 | 低 | 高 |
| 监管风险 | 低 | 高 |
风控的宽严取舍本质是误杀与漏放的权衡。过严会把正常的策略行为当成异常,导致频繁拒单影响收益;过松则可能放过真正的风险。实践中应该分级:新策略严格、成熟策略宽松;小资金宽松、大资金严格。
规则引擎与硬编码的取舍:规则引擎灵活但延迟高,硬编码快但难维护。折中方案是核心规则硬编码(延迟敏感)+ 边缘规则用引擎(可热更新)。核心规则包括金额、持仓、价格这些每笔都要查的;边缘规则包括撤单率、行为模式这些可以低频计算的。
常见坑清单
- 风控放在报单之后:事后风控无法阻止损失,必须在报单前。
- 限额用非原子变量:并发校验时多笔订单同时通过,总量超限。
- 冻结/解冻不对等:部分成交后忘记按量解冻,资金凭空消失。
- T+1 未区分可用持仓:当天买入当天卖出,报单被交易所拒绝。
- 自成交未防范:同账户对敲,构成违规。
- 熔断无自动恢复限制:异常未排除就自动恢复,反复触发。
- 告警依赖交易系统:系统故障时告警也发不出,用独立通道。
- 风控延迟未监控:风控本身成为瓶颈,拖慢所有报单。
- 限额静态不变:策略表现恶化时未收紧,风险持续累积。
- 风控日志不完整:监管检查时无法提供决策依据。
小结
风控的核心是用确定性的规则限制不确定的市场。它不产生收益,但决定了你能不能在市场里活下来。工程上的关键是三点:在关键路径上(能真正拦截)、足够快(不拖慢交易)、可审计(能回溯每个决策)。
判断风控是否合格的最快方式是故障演练:人为制造异常报单、行情中断、资金不足等场景,看风控是否能正确拦截并告警。没有演练过的风控,在真实故障时大概率会失效。
下一步建议阅读 清结算与对账体系 ,看成交之后资金与持仓如何被正确记账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。