技术博客

佣金、印花税、过户费:费用计算引擎的设计

第 18 / 141 篇 · 2026-01-10 · secfin · 佣金费用引擎规则表

客户买 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 实收费率"全量核的,多收少收都是违规。把它做成规则驱动、明细落库、可重放的纯函数,是这个模块唯一的正确打开方式。