在日常的业务逻辑开发中,特别涉及到金融业务中金额的前端展示,我们总是要慎之又慎,因为金额相关数据的展示不精确,往往容易导致客户投诉,也显得我们自己应用的不专业性。

譬如有如下展示的需求:用户共拥有A、B、C、D三类资产,需要在页面中展示这四类资产各自的占比,百分数保留两位小数。

粗看起来似乎很简单,用各自金额除以总金额不就解决了吗?但问题往往就出在这种不经意间,因为并没有考虑到精度问题。按照这种方式,无论我们对小数精度采取哪种取舍策略,都无法确保各占比值加起来刚好等于1。

那好,要保证加起来等于1是吧,那我前三项资产都直接用金额除以总额得到占比值,最后一项的占比值由1减去前三个占比值不就可以了吗?这种处理方式看起来ok,但经过严格测试我们会发现,无论对小数精度采取哪种取舍策略,都可能会出现最后一项资产的占比小于0的情况,因为精度保留后可能前三项的总和已经大于1了。比较经典的复现场景是资产的占比很悬殊的情况,比如前三项都是几千,最后一项只有一元。

一种较好的处理方案是,对原始的占比值做截断处理,保证处理后的占比值之和小于1,并记录下由于截断丢失的精度之和。然后将丢失的精度补充至各占比值,直到剩余的丢失精度之和为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private Map<String, BigDecimal> calAmountPercent(List<AmountPercentInfo> amountDistList, int decimalNum){
Map<String, BigDecimal> amountDistMap = Maps.newHashMap();
if (CollectionUtils.isEmpty(amountDistList) || decimalNum<0){
return amountDistMap;
}
// 特殊处理
if (amountDistList.size()==1){
amountDistMap.put(amountDistList.get(0).getItemKey(), BigDecimal.ONE);
return amountDistMap;
}
BigDecimal totalAmount = BigDecimal.ZERO;
for(AmountPercentInfo amountPercentInfo: amountDistList) {
totalAmount = totalAmount.add(amountPercentInfo.getItemAmount());
}
// 将资产截断后的占比放入map,同时记录误差
List<AmountPercentInfo> lostItems = Lists.newArrayList();
BigDecimal sum = BigDecimal.ZERO;
for (AmountPercentInfo percentInfo : amountDistList) {
BigDecimal source = percentInfo.getItemAmount()
.divide(totalAmount, decimalNum+2, BigDecimal.ROUND_HALF_UP);
BigDecimal reserve = source.setScale(decimalNum, BigDecimal.ROUND_DOWN);
BigDecimal lost = source.subtract(reserve);
if (lost.signum()>0){
AmountPercentInfo lostInfo = new AmountPercentInfo();
lostInfo.setItemKey(percentInfo.getItemKey());;
lostInfo.setItemPercent(lost);
lostItems.add(lostInfo);
}
amountDistMap.put(percentInfo.getItemKey(), reserve);
sum = sum.add(reserve);
}
// 修正占比
if (sum.compareTo(BigDecimal.ONE)<0){
// 修正小数截断带来的误差,优先修正误差较大的分类
BigDecimal error = BigDecimal.ONE.subtract(sum);
BigDecimal unit =BigDecimal.ONE.divide(BigDecimal.TEN.pow(decimalNum), decimalNum, BigDecimal.ROUND_HALF_UP);
Collections.sort(lostItems, new Comparator<AmountPercentInfo>() {
@Override
public int compare(AmountPercentInfo r1,
AmountPercentInfo r2) {
BigDecimal lost1 = r1.getItemPercent();
BigDecimal lost2 = r2.getItemPercent();
return lost2.compareTo(lost1);
}
});
for(AmountPercentInfo lostInfo : lostItems){
String itemKey = lostInfo.getItemKey();
amountDistMap.put(itemKey, amountDistMap.get(itemKey).add(unit));
error = error.subtract(unit);
if (error.signum()<=0){
break;
}
}
}
return amountDistMap;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class AmountPercentInfo {
public AmountPercentInfo(String itemKey, BigDecimal itemAmount) {
this.itemAmount = itemAmount;
this.itemKey = itemKey;
}
/**
* 占比项的key
*/
private String itemKey;
/**
* 占比项的金额
*/
private BigDecimal itemAmount;
/**
* 占比项的占比
*/
private BigDecimal itemPercent;
public String getItemKey() {
return itemKey;
}
public void setItemKey(String itemKey) {
this.itemKey = itemKey;
}
public BigDecimal getItemPercent() {
return itemPercent;
}
public void setItemPercent(BigDecimal itemPercent) {
this.itemPercent = itemPercent;
}
public BigDecimal getItemAmount() {
return itemAmount;
}
public void setItemAmount(BigDecimal itemAmount) {
this.itemAmount = itemAmount;
}
}

AmountPercentInfo为入参资产项,decimalNum为要求的小数精度。lostItems数组将各资产丢失的占比精度都保存起来,在后续中将优先补充丢失精度较高的资产。unit为精度补充的单位,在error为0后,停止补充。

调用示例代码:

1
2
3
4
5
public static void main(String[] args) {
List<AmountPercentInfo> amountDistList = Lists.newArrayList(new AmountPercentInfo("A", new BigDecimal(123.32)),
new AmountPercentInfo("B", new BigDecimal(76.79)), new AmountPercentInfo("C", new BigDecimal(56.12)));
System.out.println(new AssetReportSnapshotSubJob().calAmountPercent(amountDistList, 4));
}

输出如下:

1
{A=0.4813, B=0.2997, C=0.2190}

这种处理方案得出的占比值并不完全精确,但由于是对待保留小数位的后两位基础上进行截断和补充操作,精度误差较小。另外,由于只是提供前端展示,在保证和为1且均为正值的情况下,对精度不会过于苛求。

本文代码提供了一种基本的解决思路,但方式并不唯一,大家可以根据需要自行改进。比如还可以考虑若金额极小,导致精度保留后占比值为0.00%,容易给用户造成该项资产不存在的误导,是否考虑提供最低占比值如0.01%等。