Pre Trade Risk Audit Engine
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 |
|---|---|---|
| 规则 CRUD | Insert/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)