Learning
VOL. VII · NO. 128 · OTC Derivatives · 19 JUL 2026

Pre Trade Risk Audit Engine

OTC 衍生品 · 19 JUL 2026 · 7 min read · 1,284 words
· · ·

ODTS 78 — 交易前风险审核引擎(hedging-as 侧):RiskRule 落地的最后一米

为什么需要这篇文档

72 写了 eds-web-app 里的 RiskRule 规则引擎(规则怎么配置、怎么通过 ActiveMQ 广播)。73 写了风险官用的 rm-web 前端。但中间缺了最关键的一环:

规则广播出去之后,到底在”谁”身上、怎么被”执行”的?

答案是 hedging-as 里的交易前风险审核引擎——它消费 72 广播的 riskRuleId,把规则加载进内存(RiskDataPool),在每一笔交易提交时,结合 RiskCheckContext(这笔交易的上下文)实时算出”放不放行”。这是”规则配置 → 规则生效 → 交易拦截”这条链路真正闭合的地方。

24-Risk-Management-Framework 讲了风险管理的概念,但没展示这套实时、前置、逐笔的审核机制。本文补上。


1. 引擎在 hedging-as 里的位置

hedging-as/src/com/cicc/odts/risk/
├── service/
│   ├── calculation/
│   │   ├── SecurityDimensionCalcService.java  — 标的维度校验(这只股票在不在允许的安全视图里)
│   │   ├── SecurityPoolCalcService.java       — 证券池校验
│   │   └── NotionalPrincipleService.java       — 名义本金阈值校验
│   └── riskrule/
│       ├── IRiskRuleGenerator.java
│       ├── GenericRiskRuleGenerator.java
│       └── RiskRuleGenerator.java             — 从 RiskDataPool 取出并过滤适用规则
├── model/
│   ├── RiskCheckContext.java   — 单笔交易的风险审核上下文
│   ├── RiskRule.java           — 与 72 同构的规则模型
│   ├── AccountData.java        — 账户数据
│   └── Deal.java
├── dao/
│   ├── SecurityDimensionDao.java
│   ├── SecurityPoolDao.java
│   ├── NotionalPrincipleDao.java
│   ├── TradeAccountDao.java
│   └── TradingAccountDaoImpl.java
└── util/
    ├── RiskConstant.java        — 含 LOGGER_NAME、阈值常量
    └── BusinessException.java   — 审核不通过抛出的异常

protobuf:
  trade/riskAudit/
    ├── MsgRiskAudit.java             — 风险审核消息
    ├── MsgRiskAuditRecord.java       — 审核记录
    └── MsgQueryRiskAudit.java        — 查询审核

动态刷新:
  action/dynamic/RiskMemoryRefreshAction.java   — 手动刷内存规则
  action/dynamic/RunEodRiskCheckOptAction.java   — EOD 风险检查

业务定位: 这套引擎和 72 的 eds-web-app/action/risk/同一套规则的两边——72 是”规则的管理端(谁配、怎么存、怎么广播)“,本文是”规则的执行端(交易来了怎么用)”。

和 75 额度、16/73 保证金合起来,构成”提交即拦截”的三道闸(72/75 已统一口径):

文档裁什么时点
规则闸(RiskRule)72 + 本文”规则允不允许做”成交前,逐笔实时
额度闸(quota)75”额度够不够”成交前,预占
保证金闸(IM/VM)16/73”进门押多少押金”成交后 / EOD

本文是规则闸的执行端——它把 72 广播的规则在 hedging-as 内存里真正落实成”放不放行”。三道闸独立裁决、任一不满足都挡交易;本文与 72 共同撑起第一道闸的”配→播→拦”全链。


2. 一笔交易提交时,审核引擎做了什么

核心数据流:

交易提交
  → 构造 RiskCheckContext(这笔 Deal 的上下文:账户、标的、产品、名义本金…)
  → RiskRuleGenerator.generateRiskRules(ctx)
       → RiskDataPool.getRelatedRiskRule(accountData, ctx)   // 从内存取该账户相关规则
       → filterRiskRule(rules, ctx, accountData)            // 按上下文过滤出适用规则
  → 逐条规则执行:
       ├── SecurityDimensionCalcService.isInSecurityView(view, stkId, exchId)  // 标的维度
       ├── SecurityPoolCalcService                                       // 证券池
       └── NotionalPrincipleService                                      // 名义本金阈值
  → 任一不通过 → 抛 BusinessException → 交易被拒

业务含义: 审核是上下文驱动的——规则本身只是一堆条件(72 的字段),真正”这条规则对这笔交易生不生效”是在 RiskCheckContext 里判定的。filterRiskRule 把”绑定到该账户、且匹配这笔交易特征”的规则筛出来,再逐条算。


3. 三个具体校验器:引擎到底查什么

SecurityDimensionCalcService —— 标的维度

public boolean isInSecurityView(String securityView, String stkId, String exchangeId) {
    return securityViewDao.existsInDimension(stkId, exchangeId, securityView);
}

业务含义: “这只股票(stkId + exchangeId)在不在允许的’安全视图(securityView)‘里?“——这是 72 里 underlyingControlType 规则在 hedging-as 侧的具体落地。一个”禁止某板块标的”的规则,最终就是在这里查”该标的属于哪个板块维度、是否在被禁列表”。

NotionalPrincipleService —— 名义本金阈值

private static final BigDecimal COMPARE_VALUE = BigDecimal.valueOf(20000000); // 20,000,000

业务含义: 代码里写死了一个 ¥20,000,000(2000 万) 的名义本金比较阈值——超过这个数额的交易要走更严的审核路径。这暴露了一个真实的治理细节:有些风险阈值是以硬编码常量形式存在的,而不是全部走配置。这既是”快速生效”的便利,也是”改阈值要改代码发版”的隐患(呼应 09 提到的配置 vs 代码的边界问题)。

SecurityPoolCalcService —— 证券池

证券池校验:标的必须落在允许的证券池内(和 SecurityDimension 是不同粒度——池是更具体的标的集合)。


4. RiskDataPool:72 广播的最终归宿

72 里 RiskRulePublisher 非持久化广播 riskRuleId,订阅方就是这里的 RiskDataPool

private RiskDataPool riskDataPool = RiskDataPool.getInstance();  // 单例内存池
...
List<RiskRule> riskRules = riskDataPool.getRelatedRiskRule(checkContext.getAccountData(), checkContext);

最关键的设计事实: 规则不是每次都查数据库,而是常驻内存RiskDataPool 单例)。72 广播的 riskRuleId 触发 hedging-as 去拉最新规则、更新这个内存池。这就是为什么有 RiskMemoryRefreshAction——手动强制刷新内存规则。

这就把 72 的真实踩坑彻底闭环了:如果广播因为 MQ 非持久化丢了,RiskDataPool 里还是旧规则,且直到下次刷新前都不会自动纠正。72 里那个”10 分钟 hedging-as 用旧规则”的事件,根因就在这里——RiskDataPool 没被更新。


5. 和 72 的对比:同一套规则,两端职责

职责72 — eds-web-app/action/risk本文 — hedging-as/risk
规则 CRUDInsert/Delete/Find(不负责)
规则广播RiskRulePublisher (MQ)订阅、更新 RiskDataPool
规则存储DB + 内存池只读 RiskDataPool
执行点RiskCheckContext + 三个校验器
失败处理抛 BusinessException 拒交易

一句话: 72 是”规则的编辑部”,本文是”规则的检查站”。编辑部发了稿(广播),检查站按稿拦车(审核)。


6. 内行才知道的:EOD 还会再跑一遍

除了交易前逐笔审核,还有 RunEodRiskCheckOptAction——EOD 风险检查。说明同一套 riskrule / calculation 服务,既用于盘中逐笔前置拦截,也用于日终批量复核(呼应 72 ctrlTimeEod 字段:有些规则 EOD 也要算)。盘中是”挡交易”,EOD 是”日终体检”,共用一套引擎。


自测

  • 一笔交易提交时,RiskCheckContext 的作用是什么?规则本身为什么不能单独决定”放不放”?
  • RiskDataPool 是每次查库还是常驻内存?这跟 72 的 MQ 非持久化广播丢失有什么因果联系?
  • NotionalPrincipleService 里硬编码的 COMPARE_VALUE 是多少?硬编码阈值有什么利弊?
  • SecurityDimensionCalcService.isInSecurityView 查的是什么维度?它对应 72 里哪类规则?
  • 同一套 risk 服务,除了盘中拦截,还在哪个时点被调用?

相关阅读:72-Credit-and-Risk-Rules-Engine.md(规则管理端 + MQ 广播)、24-Risk-Management-Framework.md(风险框架概念)、73-Risk-Management-Web-Frontend.md(风险官 UI)