跳转到内容

案例 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
规则方法 口径 分值/输出
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 产品风险等级 - 客户承受等级(正数表示产品超出承受能力) 整数等级差

将下列文件按源码包结构放入 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 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 投资者风险承受能力测评与拟购产品要素(投资者适当性管理)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 投资者适当性匹配决策结果
*/
@Data
public 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);
}
}
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);
}
<?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>
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);
}
}
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 小时后回访确认"));
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=InvestorSuitabilityServiceTest
测试方法 构造的业务形态 关键断言/期望决策
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。