客户买 1 万元股票,成交确认上除了 1 万元本金,还有几块钱的费用。这几块钱是三个独立主体收的三种钱,计算规则各不相同,费率还因人而异。费用计算看起来是"成交金额 × 费率"的小学算术,真正难的是费率从哪来和算出分位尾数怎么办。这篇把费用引擎拆开讲。
三种费用的口径
以现行 A 股为例(费率随监管调整,以参数表为准):
| 费用 | 收取方 | 方向 | 费率 | 特殊规则 |
|---|---|---|---|---|
| 佣金 | 券商 | 买卖双向 | 客户约定,上限 0.3% | 最低 5 元 |
| 印花税 | 国家(券商代扣) | 仅卖出 | 0.05% | 无最低 |
| 过户费 | 中国结算 | 买卖双向 | 0.001%(0.01‰) | 无最低 |
三个要点决定引擎的形状。费率来源不同:佣金是券商和客户约定的商业费率,一人一个价;印花税和过户费是法定费率,全市场统一,但监管会调整(印花税 2023 年从 0.1% 降到 0.05%),所以法定费率也必须参数化、带生效日期,不能硬编码。方向不同:卖出才收印花税,方向错了就是多扣客户的钱。最低收费:佣金"万 2.5 最低 5 元"意味着 2 万元以下的成交都按 5 元收——这个规则让佣金函数不连续,是测试用例的最爱。
$$ \text{佣金} = \max(\text{成交金额} \times r_{佣金},\ 5.00) $$
费率查找链:佣金一人一个价怎么实现
佣金费率按"最具体者优先"的原则分层查找,典型的查找链是:
客户级专属费率 → 客户所属渠道/营业部费率 → 客户所属客群费率 → 公司默认费率
每一层是一张(维度键 × 品种 × 市场 → 费率)的规则表,引擎从最具体的层开始查,命中即止。这套"逐级回落(fallback chain)"设计的原因是业务现实:营业部可以给大客户单独谈价(客户级),互联网平台渠道统一一个价(渠道级),普通散户走默认价。层级设计要克制——见过查到第七层的系统,没人说得清一个客户最终命中哪条规则,营销活动和费率冲突时互相覆盖,排查靠翻日志。三到四层足够,且每层规则表要有优先级 + 生效区间 + 审批流水。
费率查找是交易热路径上的调用,但费率本身变化频率是"天"级,所以标准做法是内存缓存全量费率表 + 日切刷新 + 变更失效:日切时装载当日生效版本,运营日间改费率走"立即失效相关缓存条目"的消息通知。绝不能在下单路径上查库。
计算顺序与舍入:分位钱的纪律
一笔成交的总费用是多项之和,每项独立计算、独立舍入,最后求和:
$$ \text{总费用} = \sum_{k} \operatorname{round}_2\!\big(f_k(\text{成交金额})\big) $$
注意舍入在每项内部完成,而不是加起来再舍。原因是每项费用去向不同(佣金进券商收入科目、印花税进代扣税款科目、过户费进中登科目),分录要按项出,每项必须自己是一个合法的分位金额。舍入模式行业惯例是分位四舍五入(round half up),这个选择本身不重要,重要的是全系统只能有一个舍入函数——柜台、清算、核算三处各自实现 round 的系统,迟早出现"柜台算 5.005 进位、清算算 5.005 舍去"的一分钱差异,这种差异在对账里是最恶心的那类:单笔一分钱,日终汇总几百笔挂出来,逐笔查到死。
舍入纪律写成代码规范就是:金额一律 DECIMAL,舍入只许调用公共库的一个函数,任何业务代码里出现 / 100、+ 0.005 这类手搓舍入直接评审打回。
引擎结构:规则驱动,不是硬编码
把上面所有要素拼起来,费用引擎的核心是一个纯函数加一个规则库:
fee_engine.fill(fill) # fill = 成交(金额, 方向, 品种, 市场, 客户)
→ for each fee_item in 规则库[品种, 市场]: # 该品种有哪些费用项
rate = resolve_rate(fee_item, 客户) # 走查找链
base = fee_item.base_of(fill) # 计费基数(多数是成交金额)
amt = round2(base × rate)
amt = clamp(amt, fee_item.min, fee_item.max) # 最低/最高收费
emit 费用明细(fee_item, amt) # 逐项出明细
两个设计决策值得说透。
输出是费用明细列表,不是一个总费用数。 明细要落库(成交 × 费用项 × 金额),因为后续每个消费方要的东西不一样:客户交割单要展示明细,核算要按费用项进不同科目,清算要和交易所/中登的费用口径核对,监管报送要汇总印花税代扣金额。只存一个总数,这些需求全都得回头拆数。
引擎必须纯函数化、可重放。 同一笔成交、同一套费率版本,任何时候重算结果必须逐分位一致。这要求费率按生效日期取版本(清算补跑历史日期时用的是当时的费率,不是今天的),舍入函数全局唯一,引擎不读任何易变全局状态。可重放性在"费率配错了,重算上周全部成交费用"这种事故里是救命的。
边界案例:测试要盯的地方
费用引擎的 bug 几乎全在边界上,列几个必测的:
- 最低收费边界:佣金恰好 4.9999 元(进最低 5 元)和恰好 5.00 元(不进)的相邻两笔;
- 零股/碎股:100 股以下卖出按实际金额计,基数很小,最低收费主导;
- 大宗交易/协议转让:佣金费率常有单独约定,基数口径可能不同;
- 债券/场内基金:印花税不征(印花税只对股票),费用项集合随品种变——规则库按品种驱动的重要性在这;
- 费用变更日:费率调整生效日当天的成交,按成交时间还是按清算日期取费率?口径要提前和中登对齐,写进规则。
费用引擎金额小、规则杂、变化频,是典型的"低单额高风险"模块:单笔几分钱的差错客户不会发现,但监管检查和外部审计是按"应收费率 vs 实收费率"全量核的,多收少收都是违规。把它做成规则驱动、明细落库、可重放的纯函数,是这个模块唯一的正确打开方式。