案例 19:理财客户风险等级匹配测评
本案例对应 USE-CASES 场景 19「理财客户风险等级匹配测评」,属于金融合规领域,面向银行、券商等理财销售场景的投资者适当性管理,自动完成客户成熟度测评、产品风险等级匹配与可购/双录确认/禁购决策。
依据《证券期货投资者适当性管理办法》,金融机构向客户销售理财产品前必须完成适当性匹配:客户要素包括年龄、年收入、家庭金融净资产、投资经验年限、风险承受能力问卷等级(C1 保守型至 C5 激进型)、拟购产品风险等级(R1 低风险至 R5 高风险)、产品名称以及风险揭示书签署状态。系统要在客户下单购买的瞬间给出留痕完备的匹配结论,避免错配带来的合规风险。
决策分前置校验与匹配两层。Service 先做两条前置阻断:客户未满 18 周岁不具备购买金融产品的民事行为能力;未签署对应产品风险揭示书的,须先完成签署与风险测评。通过后规则脚本进行五维成熟度评分:年龄(0-15,25-50 岁投资生涯最稳健)、收入(0-15)、资产(0-20)、投资经验(0-20)、风险承受问卷(0-30),合计满分 100。
匹配结论分三态。产品风险等级高于客户承受等级(R 级大于 C 级,等级差为正)属于适当性硬阻断 BLOCK,依据监管办法禁止销售,且该政策写在 Java 侧、不随评分脚本调整;等级匹配但客户年满 65 周岁高龄购买 R3 及以上中高风险产品、或成熟度评分低于 50 分购买 R3 及以上产品的,结论为 CONFIRM,必须网点双录(录音录像)——高龄客户由成年直系亲属陪同确认,低成熟度客户须手抄特别风险提示并经过 24 小时冷静期回访;其余情形 ALLOW 直接放行。决策结果同时回填等级差、该客户可购的最高产品风险等级与每条阻断/限制原因,直接用于销售页面与合规留痕。
- 监管红线与评分调优必须分层:越级销售(R 级高于 C 级)是法规硬阻断,不能因为成熟度评分高就放开;而成熟度问卷口径又需要持续调优,两类逻辑混在一起容易误放开。
- 问卷与测评口径散落各渠道:年龄、收入、资产、经验、问卷五档分值写死在手机银行、柜台等不同系统中,口径不一致会造成同客户不同渠道结论不同。
- 双录触发规则组合复杂:高龄购中高风险、低成熟度购中高风险两条路径并存,且后续话术不同(家属陪同 vs 手抄+冷静期),人工执行容易漏判或错用话术。
- 合规留痕要求高:每一笔业务都要能回溯客户等级、产品等级、等级差、阻断原因与评分明细,硬编码决策难以输出结构化证据。
- 揭示书签署、民事行为能力等前置条件容易遗漏:若先算评分再校验签署状态,未签客户也会看到分数和产品信息,流程不合规。
- 老旧理财销售系统仍在 JDK 8、信创环境难引入重型合规规则平台:需要无独立中间件、能随销售交易链路嵌入式执行的轻量规则能力。
本案例沿用六件套骨架:入参模型 + 决策结果模型 + 规则接口 + XML 规则脚本 + Service 编排 + 6 组真实业务形态测试。
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型 | 客户测评与拟购产品要素、适当性匹配决策结果(三态结论/等级差/双录/原因) | InvestorAssessment.java、SuitabilityDecision.java |
| 规则集与 XML | 五维成熟度评分、双录确认条件、风险等级差 | InvestorSuitabilityRules.java、investor-suitability.xml |
| 编排服务 | 年龄/签署前置阻断、越级硬阻断、双录分流与原因组装 | InvestorSuitabilityService.java |
| 测试 | 6 组客户形态(高净值/保守存款/越级/高龄/未签署/低成熟度) | InvestorSuitabilityServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
scoreAge |
25-50 岁 15、51-60 岁 12、18-24 岁 8、61-64 岁 6、其余 3 | 0-15 |
scoreIncome |
年收入 ≥50 万 15、≥20 万 12、≥10 万 8、≥5 万 4、否则 2 | 0-15 |
scoreAssets |
家庭金融净资产 ≥300 万 20、≥100 万 15、≥30 万 10、≥10 万 5、否则 2 | 0-20 |
scoreExperience |
投资经验 ≥10 年 20、≥5 年 15、≥3 年 10、≥1 年 5、否则 0 | 0-20 |
scoreTolerance |
风险承受等级 C5/C4/C3/C2/其余 得 30/24/18/10/5 | 0-30 |
needConfirm |
成熟度低于 50 购买 R3 及以上,或高龄客户购买 R3 及以上,返回 true | boolean |
rankGap |
产品风险等级 - 客户承受等级(正数表示产品超出承受能力) | 整数等级差 |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将下列文件按源码包结构放入 JQuick-Java 工程的测试目录:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试类放入 src/test/java/com/github/paohaijiao/demo/,Service 放入 src/test/java/com/github/paohaijiao/demo/service/,XML 脚本放入 src/test/resources/demo/,包名保持源码原样(com.github.paohaijiao.demo / .model / .service)。放入后执行 mvn test -Dtest=InvestorSuitabilityServiceTest 即可运行全部 6 个用例。
InvestorAssessment.java
Section titled “InvestorAssessment.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 投资者风险承受能力测评与拟购产品要素(投资者适当性管理) */@Data@NoArgsConstructor@AllArgsConstructorpublic class InvestorAssessment {
/** * 客户号 */ private String investorId;
/** * 客户姓名 */ private String investorName;
/** * 年龄 */ private int age;
/** * 年收入(元) */ private double annualIncome;
/** * 家庭金融净资产(元) */ private double netAssets;
/** * 投资经验年限 */ private int investExperienceYears;
/** * 风险承受能力等级(1-5,对应 C1 保守型至 C5 激进型) */ private int riskToleranceRank;
/** * 拟购产品风险等级(1-5,对应 R1 低风险至 R5 高风险) */ private int productRiskRank;
/** * 拟购产品名称 */ private String productName;
/** * 是否已签署产品风险揭示书 */ private boolean signedRiskNotice;}SuitabilityDecision.java
Section titled “SuitabilityDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 投资者适当性匹配决策结果 */@Datapublic class SuitabilityDecision {
/** * 客户号 */ private String investorId;
/** * 匹配结论:ALLOW-可购买 / CONFIRM-确认后购买(双录+特别风险揭示) / BLOCK-禁止购买 */ private String decision;
/** * 投资者成熟度评分(0-100) */ private int maturityScore;
/** * 风险承受等级(1-5) */ private int riskToleranceRank;
/** * 产品风险等级(1-5) */ private int productRiskRank;
/** * 风险等级差(产品等级 - 承受等级,正数表示产品超出承受能力) */ private int rankGap;
/** * 该客户允许购买的最高产品风险等级 */ private int maxAllowedRank;
/** * 是否必须双录(录音录像)并手抄风险提示 */ private boolean doubleRecordingRequired;
/** * 阻断/限制原因 */ private List<String> reasons = new ArrayList<String>();
/** * 评分明细 */ private List<String> scoreDetails = new ArrayList<String>();
public void addReason(String reason) { this.reasons.add(reason); }
public void addScoreDetail(String detail) { this.scoreDetails.add(detail); }}InvestorSuitabilityRules.java
Section titled “InvestorSuitabilityRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 19(生产级样板):投资者适当性匹配规则集 * <p> * 五维成熟度评分(年龄 / 收入 / 资产 / 经验 / 风险承受问卷),再按产品与承受等级差、 * 高龄、低成熟度决定可购/确认/禁购;风险揭示书签署校验由 Service 前置。 * 规则脚本见 {@code src/test/resources/demo/investor-suitability.xml}。 */public interface InvestorSuitabilityRules {
/** * 年龄成熟度评分(0-15) */ int scoreAge(@Param("age") int age);
/** * 收入成熟度评分(0-15) */ int scoreIncome(@Param("annualIncome") double annualIncome);
/** * 资产成熟度评分(0-20) */ int scoreAssets(@Param("netAssets") double netAssets);
/** * 投资经验评分(0-20) */ int scoreExperience(@Param("investExperienceYears") int investExperienceYears);
/** * 风险承受问卷评分(0-30) */ int scoreTolerance(@Param("riskToleranceRank") int riskToleranceRank);
/** * 是否需要确认(双录):高龄客户或低成熟度客户购买 R3 及以上产品时触发 */ boolean needConfirm(@Param("maturityScore") int maturityScore, @Param("productRiskRank") int productRiskRank, @Param("elderly") boolean elderly);
/** * 风险等级差(产品 - 承受) */ int rankGap(@Param("riskToleranceRank") int riskToleranceRank, @Param("productRiskRank") int productRiskRank);}investor-suitability.xml
Section titled “investor-suitability.xml”<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN" "classpath:paohaijiao/dtd/Jquick-java.dtd"><javas namespace="com.github.paohaijiao.demo.InvestorSuitabilityRules">
<!-- 年龄成熟度评分:0-15,25-50 岁投资生涯最稳健 --> <java name="scoreAge" returnClass="int"> <![CDATA[ int def scoreAge(int:age) { if (age >= 25 && age <= 50) { return 15; } else if (age >= 51 && age <= 60) { return 12; } else if (age >= 18 && age <= 24) { return 8; } else if (age >= 61 && age <= 64) { return 6; } else { return 3; } } ]]> </java>
<!-- 收入成熟度评分:0-15 --> <java name="scoreIncome" returnClass="int"> <![CDATA[ int def scoreIncome(double:annualIncome) { if (annualIncome >= 500000.0) { return 15; } else if (annualIncome >= 200000.0) { return 12; } else if (annualIncome >= 100000.0) { return 8; } else if (annualIncome >= 50000.0) { return 4; } else { return 2; } } ]]> </java>
<!-- 资产成熟度评分:0-20 --> <java name="scoreAssets" returnClass="int"> <![CDATA[ int def scoreAssets(double:netAssets) { if (netAssets >= 3000000.0) { return 20; } else if (netAssets >= 1000000.0) { return 15; } else if (netAssets >= 300000.0) { return 10; } else if (netAssets >= 100000.0) { return 5; } else { return 2; } } ]]> </java>
<!-- 投资经验评分:0-20 --> <java name="scoreExperience" returnClass="int"> <![CDATA[ int def scoreExperience(int:investExperienceYears) { if (investExperienceYears >= 10) { return 20; } else if (investExperienceYears >= 5) { return 15; } else if (investExperienceYears >= 3) { return 10; } else if (investExperienceYears >= 1) { return 5; } else { return 0; } } ]]> </java>
<!-- 风险承受问卷评分:0-30 --> <java name="scoreTolerance" returnClass="int"> <![CDATA[ int def scoreTolerance(int:riskToleranceRank) { if (riskToleranceRank >= 5) { return 30; } else if (riskToleranceRank == 4) { return 24; } else if (riskToleranceRank == 3) { return 18; } else if (riskToleranceRank == 2) { return 10; } else { return 5; } } ]]> </java>
<!-- 是否需要确认(双录):高龄购中高风险,或低成熟度购中高风险 --> <java name="needConfirm" returnClass="boolean"> <![CDATA[ boolean def needConfirm(int:maturityScore, int:productRiskRank, boolean:elderly) { boolean confirm = false; if (maturityScore < 50) { if (productRiskRank >= 3) { confirm = true; } } if (elderly) { if (productRiskRank >= 3) { confirm = true; } } return confirm; } ]]> </java>
<!-- 风险等级差 --> <java name="rankGap" returnClass="int"> <![CDATA[ int def rankGap(int:riskToleranceRank, int:productRiskRank) { return productRiskRank - riskToleranceRank; } ]]> </java>
</javas>InvestorSuitabilityService.java
Section titled “InvestorSuitabilityService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.InvestorSuitabilityRules;import com.github.paohaijiao.demo.model.InvestorAssessment;import com.github.paohaijiao.demo.model.SuitabilityDecision;
/** * 场景 19:投资者适当性匹配服务 * <p> * 规则侧(XML):成熟度评分口径、等级差与确认/禁购阈值。 * Java 侧:年龄与签署前置校验、阻断原因组装、双录要求与可购等级回填。 */public class InvestorSuitabilityService {
/** * 高龄投资者门槛 */ private static final int ELDERLY_AGE = 65;
private final InvestorSuitabilityRules rules;
public InvestorSuitabilityService(InvestorSuitabilityRules rules) { this.rules = rules; }
public SuitabilityDecision evaluate(InvestorAssessment assessment) { SuitabilityDecision decision = new SuitabilityDecision(); decision.setInvestorId(assessment.getInvestorId()); decision.setRiskToleranceRank(assessment.getRiskToleranceRank()); decision.setProductRiskRank(assessment.getProductRiskRank()); decision.setMaxAllowedRank(assessment.getRiskToleranceRank()); decision.setRankGap(rules.rankGap( assessment.getRiskToleranceRank(), assessment.getProductRiskRank()));
if (assessment.getAge() < 18) { block(decision, "客户未满 18 周岁,不具备金融产品购买民事行为能力"); return decision; } if (!assessment.isSignedRiskNotice()) { block(decision, "未签署《" + assessment.getProductName() + "》产品风险揭示书,须先完成签署与风险测评"); return decision; }
int ageScore = rules.scoreAge(assessment.getAge()); int incomeScore = rules.scoreIncome(assessment.getAnnualIncome()); int assetsScore = rules.scoreAssets(assessment.getNetAssets()); int experienceScore = rules.scoreExperience(assessment.getInvestExperienceYears()); int toleranceScore = rules.scoreTolerance(assessment.getRiskToleranceRank()); int totalScore = ageScore + incomeScore + assetsScore + experienceScore + toleranceScore; if (totalScore > 100) { totalScore = 100; }
decision.addScoreDetail("年龄成熟度评分: " + ageScore + " / 15"); decision.addScoreDetail("收入成熟度评分: " + incomeScore + " / 15"); decision.addScoreDetail("资产成熟度评分: " + assetsScore + " / 20"); decision.addScoreDetail("投资经验评分: " + experienceScore + " / 20"); decision.addScoreDetail("风险承受问卷评分: " + toleranceScore + " / 30"); decision.addScoreDetail("汇总评分: " + totalScore); decision.setMaturityScore(totalScore);
// 产品风险高于承受等级:适当性硬阻断(Java 侧硬政策,不随评分脚本调整) if (assessment.getProductRiskRank() > assessment.getRiskToleranceRank()) { decision.setDecision("BLOCK"); decision.addReason("产品风险等级 R" + assessment.getProductRiskRank() + " 高于客户风险承受等级 C" + assessment.getRiskToleranceRank() + ",依据《证券期货投资者适当性管理办法》禁止向其销售"); return decision; }
boolean elderly = assessment.getAge() >= ELDERLY_AGE; if (rules.needConfirm(totalScore, assessment.getProductRiskRank(), elderly)) { decision.setDecision("CONFIRM"); decision.setDoubleRecordingRequired(true); if (elderly) { decision.addReason("客户年满 " + ELDERLY_AGE + " 周岁购买 R" + assessment.getProductRiskRank() + " 中高风险产品,须在网点双录并由成年直系亲属陪同确认"); } else { decision.addReason("客户投资成熟度评分 " + totalScore + " 低于 50,购买 R" + assessment.getProductRiskRank() + " 产品须双录并手抄特别风险提示,冷静期 24 小时后回访确认"); } return decision; }
decision.setDecision("ALLOW"); return decision; }
private void block(SuitabilityDecision decision, String reason) { decision.setDecision("BLOCK"); decision.setMaturityScore(0); decision.addReason(reason); }}InvestorSuitabilityServiceTest.java
Section titled “InvestorSuitabilityServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.InvestorAssessment;import com.github.paohaijiao.demo.model.SuitabilityDecision;import com.github.paohaijiao.demo.service.InvestorSuitabilityService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 19 生产级样板:理财客户风险等级适当性匹配 * <p> * 覆盖 6 组客户形态:C5 高净值购 R4、C2 保守购 R1 存款、C2 越级购 R3 禁购、 * 高龄客户购 R3 双录确认、未签揭示书阻断、低成熟度客户购 R4 冷静期确认。 */public class InvestorSuitabilityServiceTest {
private InvestorSuitabilityService service;
@Before public void setUp() { InvestorSuitabilityRules rules = JQuickJava.create() .createApi(InvestorSuitabilityRules.class, "demo/investor-suitability.xml"); this.service = new InvestorSuitabilityService(rules); }
/** * C5 高净值客户购买 R4 偏股型基金:100 分直接放行 */ @Test public void shouldAllowSophisticatedInvestor() { InvestorAssessment assessment = new InvestorAssessment( "INV001", "高净值客户韩冰", 45, 800000.0, 5000000.0, 12, 5, 4, "沪深300指数增强基金", true);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals(100, decision.getMaturityScore()); Assert.assertEquals("ALLOW", decision.getDecision()); Assert.assertEquals(-1, decision.getRankGap()); Assert.assertFalse(decision.isDoubleRecordingRequired()); }
/** * C2 保守型年轻客户购买 R1 大额存单:成熟度虽低但 R1 不触发确认,直接放行 */ @Test public void shouldAllowConservativeInvestorForDeposit() { InvestorAssessment assessment = new InvestorAssessment( "INV002", "职场新人许晴", 30, 120000.0, 150000.0, 1, 2, 1, "三年期大额存单", true);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals(43, decision.getMaturityScore()); Assert.assertEquals("ALLOW", decision.getDecision()); Assert.assertEquals(2, decision.getMaxAllowedRank()); Assert.assertTrue(decision.getReasons().isEmpty()); }
/** * C2 客户越级购买 R3 混合基金:适当性硬阻断 */ @Test public void shouldBlockMismatchedRiskProduct() { InvestorAssessment assessment = new InvestorAssessment( "INV003", "保守客户冯鑫", 35, 150000.0, 200000.0, 3, 2, 3, "平衡型混合基金", true);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals("BLOCK", decision.getDecision()); Assert.assertEquals(1, decision.getRankGap()); Assert.assertTrue(decision.getReasons().contains( "产品风险等级 R3 高于客户风险承受等级 C2,依据《证券期货投资者适当性管理办法》禁止向其销售")); }
/** * 68 岁 C3 客户购买 R3 债券基金:可购但必须网点双录、家属陪同 */ @Test public void shouldRequireDoubleRecordingForElderly() { InvestorAssessment assessment = new InvestorAssessment( "INV004", "老年客户沈奶奶", 68, 300000.0, 2000000.0, 8, 3, 3, "中短债基金", true);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals(63, decision.getMaturityScore()); Assert.assertEquals("CONFIRM", decision.getDecision()); Assert.assertTrue(decision.isDoubleRecordingRequired()); Assert.assertTrue(decision.getReasons().contains( "客户年满 65 周岁购买 R3 中高风险产品,须在网点双录并由成年直系亲属陪同确认")); }
/** * 未签署风险揭示书:前置阻断 */ @Test public void shouldBlockWithoutSignedRiskNotice() { InvestorAssessment assessment = new InvestorAssessment( "INV005", "未签揭示书客户", 40, 600000.0, 3000000.0, 10, 4, 4, "私募股权投资基金", false);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals("BLOCK", decision.getDecision()); Assert.assertTrue(decision.getReasons().contains( "未签署《私募股权投资基金》产品风险揭示书,须先完成签署与风险测评")); }
/** * 低成熟度 C4 年轻客户购买 R4 产品:评分 43,双录 + 手抄 + 24 小时冷静期 */ @Test public void shouldConfirmLowMaturityInvestor() { InvestorAssessment assessment = new InvestorAssessment( "INV006", "新手投资者蒋帆", 23, 80000.0, 80000.0, 1, 4, 4, "行业主题股票基金", true);
SuitabilityDecision decision = service.evaluate(assessment);
Assert.assertEquals(43, decision.getMaturityScore()); Assert.assertEquals("CONFIRM", decision.getDecision()); Assert.assertTrue(decision.isDoubleRecordingRequired()); Assert.assertTrue(decision.getReasons().contains( "客户投资成熟度评分 43 低于 50,购买 R4 产品须双录并手抄特别风险提示,冷静期 24 小时后回访确认")); }}在工程根目录执行:
mvn test -Dtest=InvestorSuitabilityServiceTest测试覆盖形态
Section titled “测试覆盖形态”| 测试方法 | 构造的业务形态 | 关键断言/期望决策 |
|---|---|---|
shouldAllowSophisticatedInvestor |
45 岁、年收入 80 万、净资产 500 万、12 年经验、C5 购 R4 指增基金,已签署 | 成熟度 100 分 ALLOW;等级差 -1;不要求双录 |
shouldAllowConservativeInvestorForDeposit |
30 岁、年收入 12 万、净资产 15 万、1 年经验、C2 购 R1 大额存单 | 成熟度 43 分 ALLOW(R1 不触发双录);可购最高等级回填 2;原因列表为空 |
shouldBlockMismatchedRiskProduct |
35 岁、C2 越级购买 R3 平衡混合基金 | BLOCK;等级差 1;原因含「R3 高于 C2,依据《证券期货投资者适当性管理办法》禁止向其销售」 |
shouldRequireDoubleRecordingForElderly |
68 岁、净资产 200 万、8 年经验、C3 购 R3 中短债基金 | 成熟度 63 分 CONFIRM;双录标记 true;原因含年满 65 周岁网点双录、成年直系亲属陪同 |
shouldBlockWithoutSignedRiskNotice |
40 岁、C4 购 R4 私募股权基金,signedRiskNotice=false |
BLOCK;原因含「未签署《私募股权投资基金》产品风险揭示书,须先完成签署与风险测评」 |
shouldConfirmLowMaturityInvestor |
23 岁、年收入 8 万、净资产 8 万、1 年经验、C4 购 R4 行业主题股票基金 | 成熟度 43 分 CONFIRM;双录 true;原因含评分低于 50、手抄特别风险提示、24 小时冷静期回访 |
- 合规硬政策与可调评分分层落地:越级禁售(产品 R 级高于客户 C 级)写在 Service 并注释「Java 侧硬政策,不随评分脚本调整」;五维成熟度口径外置 XML 可持续调优,两类变更互不污染。
- 前置阻断先于评分:未满 18 岁、未签风险揭示书在任何评分动作之前短路 BLOCK 且成熟度置 0,未签客户不会接触测评与产品信息。
- 双录触发规则脚本化、分流话术 Java 侧组装:
needConfirm统一判定高龄与低成熟度两类触发条件,Service 再按高龄/低成熟度分别生成「家属陪同」与「手抄+24 小时冷静期」两套合规原因。 - 脚本不支持
!取反的正向写法:needConfirm中用两个正向if累积局部布尔变量confirm表达「低成熟度或高龄」的或逻辑,规避取反语法限制;rankGap返回有符号整数差,正负号直接表达越级方向。 - 结构化决策直接服务合规留痕:三态结论 ALLOW/CONFIRM/BLOCK、等级差、可购最高等级、双录标记、分维度评分明细与逐条原因全部回填,销售页与审计台账同源。
@Param按名绑定 + 代理复用 + ASM 调用链:7 个规则方法共用一个动态代理,随交易链路嵌入式调用;Java 8 无重型依赖,可运行于老旧理财系统与信创配套 JDK。