11.2 汇率、手续费与到账周期

上一节算清了门槛,这一节算「到手的钱」:毛收入先被平台分成砍一刀,再按汇率换成目标币,再被提现手续费砍一刀,最后才落进你的账户。本节用 S 层 Python 仿真把这条链写成整数模型(汇率用百万分比整数、手续费千分比、周期整数天、全程地板除),对照三种收款方式与四个目标币。

站内那篇独立游戏全球财务与多币种架构 把多币种账户、税务主体、结算通道这些结构性问题讲清楚了。本节不碰结构,只把其中最容易被浮点数坑掉的一段——从毛收入到净到账的那条算术链——做成一条可跑的整数模型。上一节(11.1)算的是「钱够不够门槛」,本节算的是「过了门槛之后,到手缩水了多少」。

汇率不是一个数字,是一个整数比

先说本节最重要的一条纪律:汇率绝不能用浮点承载。原因不是洁癖,是算术事实。在 IEEE-754 双精度下,1.15 * 100 等于 114.99999999999999,Math.floor 之后是 114 而不是 115;0.1 + 0.2 等于 0.30000000000000004。也就是说,只要汇率在系统里以「一个元兑另一个元」的浮点数流转一次,某些金额在换算时就会凭空少一分或多一分——对单笔不起眼,对成千上万笔结算就是一笔对不上的账。

本节的做法是:汇率用「目标币最小单位 / 源币最小单位 × 1000000」的整数比承载。比如美元兑人民币,1 美分 ≈ 7.2 人民币分,就记作 7200000;手续费一律用整数千分比(15 = 1.5%),到账周期用整数天数。整条链没有一次浮点运算。

一条链:毛收入 → 分成 → 换汇 → 手续费 → 净到账

把结算写成整数公式(金额统一用「最小单位」整数,源币为美元即分):

afterCommission = grossSourceMinor × (1000 − commissionPermille) // 1000   # 源币最小单位
afterFx         = afterCommission × rateMillionths // 1000000              # 目标币最小单位
payoutFee       = afterFx × feePermille // 1000 + fixedFeeMinor            # 目标币最小单位
netReceived     = afterFx − payoutFee

四个环节依次是:平台分成(从毛收入里先扣,commissionPermille)、换汇(用百万分比整数比,地板除一次)、提现手续费(比例 + 固定)、净到账。注意分成发生在换汇之前——平台是按源币口径结算的,换汇是你自己的事。顺序搞反,算出来的净额就全错了。

S 层:整数结算仿真

下面这段是 S 层仿真脚本 sim/g11_fx_fee_payout.py。它读 config/g11_fx_fee.json,按上面的整数公式把整条链算到底,做两次对照:① 同一目标币(CNY)下三种提现方式的净到账与周期;② 同一方式(Payoneer)下四个目标币的净到账。产出 JSON + CSV + SVG:

#!/usr/bin/env python3
# file: sim/g11_fx_fee_payout.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g11_fx_fee_payout.py --config config/ --out out/
"""跨境结算的汇率 / 手续费 / 到账周期整数模型。

读 config/g11_fx_fee.json,全程整数、地板除、无浮点:

    afterCommission = grossSourceMinor * (1000 - commissionPermille) // 1000   # 源币最小单位
    afterFx         = afterCommission * rateMillionths // 1000000              # 目标币最小单位
    payoutFee       = afterFx * feePermille // 1000 + fixedFeeMinor            # 目标币最小单位
    netReceived     = afterFx - payoutFee
    effectivePermille = netReceived * 1000 // grossSourceMinor                 # 净到账 / 毛收入

汇率一律用「目标币最小单位 / 源币最小单位 × 1000000」的整数比承载(例 USD→CNY
7200000 表示 1 美分 = 7.2 人民币分),**绝不用浮点**;手续费用整数千分比 + 固定费,
到账周期用整数天数。模型做两次对照:① 同一目标币下三种提现方式的净到账与周期;
② 同一方式下四个目标币的净到账。产出 out/g11_fx_fee_payout.json + .csv + .svg。

汇率、手续费、到账周期都是「公开可观察机制」口径的建模假设;真实汇率逐秒变动、
真实手续费与真实到账需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def after_commission(gross_minor: int, commission_permille: int) -> int:
    return gross_minor * (1000 - commission_permille) // 1000


def apply_fx(source_minor: int, rate_millionths: int) -> int:
    """源币最小单位 → 目标币最小单位,整数乘除 + 地板除一次,无浮点。"""
    return source_minor * rate_millionths // 1000000


def payout_fee(target_minor: int, fee_permille: int, fixed_minor: int) -> int:
    return target_minor * fee_permille // 1000 + fixed_minor


def settle_method(amount_source_minor: int, rate_millionths: int, m: dict) -> dict:
    after_fx = apply_fx(amount_source_minor, rate_millionths)
    fee = payout_fee(after_fx, m["feePermille"], m["fixedFeeMinor"])
    net = after_fx - fee
    return {"method": m["id"], "name": m["name"], "afterFxMinor": after_fx,
            "feeMinor": fee, "netMinor": net, "cycleDays": m["cycleDays"]}


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "g11_fx_fee.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    gross = cfg["grossSourceMinor"]
    after_comm = after_commission(gross, cfg["commissionPermille"])
    target = cfg["primaryTarget"]
    pair_by_to = {p["to"]: p for p in cfg["pairs"]}
    rate_target = pair_by_to[target]["rateMillionths"]

    # ① 同目标币下,三种提现方式的净到账与周期
    methods = [settle_method(after_comm, rate_target, m) for m in cfg["methods"]]
    methods.sort(key=lambda r: r["method"])
    best = max(methods, key=lambda r: r["netMinor"])

    # ② 同一参考方式下,四个目标币的净到账
    ref = next(m for m in cfg["methods"] if m["id"] == cfg["referenceMethod"])
    pairs = []
    for p in cfg["pairs"]:
        r = settle_method(after_comm, p["rateMillionths"], ref)
        pairs.append({"to": p["to"], "rateMillionths": p["rateMillionths"],
                      "afterFxMinor": r["afterFxMinor"], "feeMinor": r["feeMinor"],
                      "netMinor": r["netMinor"], "cycleDays": r["cycleDays"]})

    digest = (f'{gross}:{after_comm}:{target}:'
              + ",".join(f'{r["method"]}:{r["afterFxMinor"]}:{r["feeMinor"]}:{r["netMinor"]}:{r["cycleDays"]}'
                         for r in methods)
              + "|"
              + ",".join(f'{r["to"]}:{r["afterFxMinor"]}:{r["feeMinor"]}:{r["netMinor"]}' for r in pairs))

    svg_bytes = draw_svg(os.path.join(args.out, "g11_fx_fee_payout.svg"), gross, after_comm, methods, pairs)

    os.makedirs(args.out, exist_ok=True)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "grossSourceMinor": gross, "afterCommissionMinor": after_comm,
              "commissionPermille": cfg["commissionPermille"], "primaryTarget": target,
              "referenceMethod": cfg["referenceMethod"], "methods": methods, "pairs": pairs,
              "bestMethod": best["method"], "digest": digest,
              "svg": os.path.join(args.out, "g11_fx_fee_payout.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g11_fx_fee_payout.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g11_fx_fee_payout.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["group", "key", "afterFxMinor", "feeMinor", "netMinor", "cycleDays"])
        for r in methods:
            w.writerow(["method", r["method"], r["afterFxMinor"], r["feeMinor"], r["netMinor"], r["cycleDays"]])
        for r in pairs:
            w.writerow(["pair", r["to"], r["afterFxMinor"], r["feeMinor"], r["netMinor"], r["cycleDays"]])

    print(json.dumps({"digest": digest, "grossSourceMinor": gross,
                      "afterCommissionMinor": after_comm, "primaryTarget": target,
                      "bestMethod": best["method"], "methods": methods, "pairs": pairs},
                     ensure_ascii=False, indent=2))
    return 0


def draw_svg(path: str, gross: int, after_comm: int, methods: list[dict], pairs: list[dict]) -> int:
    """纯 Python SVG:上排三种方式的净到账横条(标周期天数),下排四个币种的净到账。"""
    W, PAD, ROW = 800, 230, 30
    H = 76 + (len(methods) + len(pairs)) * ROW + 40
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
        f'《合成小岛》跨境结算:毛收入 {gross} 分 → 平台分成后 {after_comm} 分(源币)')
    span = W - PAD - 24
    y = 56
    ET.SubElement(root, "text", {"x": "16", "y": str(y), "font-size": "13", "fill": "#374151"}).text = "① 同目标币(CNY):三种提现方式净到账(目标币分)"
    y += 18
    hi = max([r["netMinor"] for r in methods] + [1])
    for r in methods:
        w = span * r["netMinor"] // hi
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12", "fill": "#374151"}).text = r["name"]
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 2), "width": str(w),
                                     "height": "18", "rx": "3", "fill": "#2563eb"})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 16), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'{r["netMinor"]} · {r["cycleDays"]}天'
        y += ROW
    y += 20
    ET.SubElement(root, "text", {"x": "16", "y": str(y), "font-size": "13", "fill": "#374151"}).text = "② 同方式(Payoneer):四个目标币净到账"
    y += 18
    hi2 = max([r["netMinor"] for r in pairs] + [1])
    for r in pairs:
        w = span * r["netMinor"] // hi2
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12", "fill": "#374151"}).text = f'USD→{r["to"]}'
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 2), "width": str(w),
                                     "height": "18", "rx": "3", "fill": "#0ea5e9"})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 16), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'{r["netMinor"]}'
        y += ROW
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/g11_fx_fee.json。再次强调:这些汇率、手续费与周期是「公开可观察机制」口径的建模假设,不是任何平台后台的实测;真实汇率逐秒变动属于 X 层。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "跨境结算的汇率 / 手续费 / 到账周期整数模型参数。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):grossSourceMinor=本期平台侧毛收入(源币最小单位,源币为 USD,最小单位是分);commissionPermille=平台分成千分比(从毛收入里先扣);pairs[].rateMillionths=目标币最小单位 / 源币最小单位 × 1000000 的整数比(例 USD→CNY 7200000 表示 1 美分 = 7.2 人民币分);methods[].feePermille=提现手续费千分比、fixedFeeMinor=每笔固定手续费(目标币最小单位)、cycleDays=到账周期整数天数;primaryTarget=做方式对照的目标币;referenceMethod=做币种对照时统一采用的方式。真实汇率逐秒变动、真实手续费与真实到账需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "grossSourceMinor": 50000,
  "commissionPermille": 300,
  "primaryTarget": "CNY",
  "referenceMethod": "payoneer",
  "pairs": [
    { "from": "USD", "to": "CNY", "rateMillionths": 7200000 },
    { "from": "USD", "to": "EUR", "rateMillionths": 920000 },
    { "from": "USD", "to": "JPY", "rateMillionths": 1570000 },
    { "from": "USD", "to": "GBP", "rateMillionths": 780000 }
  ],
  "methods": [
    { "id": "bank_wire", "name": "银行电汇", "feePermille": 0, "fixedFeeMinor": 18000, "cycleDays": 30 },
    { "id": "paypal", "name": "PayPal", "feePermille": 20, "fixedFeeMinor": 0, "cycleDays": 14 },
    { "id": "payoneer", "name": "Payoneer", "feePermille": 15, "fixedFeeMinor": 0, "cycleDays": 7 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/g11_fx_fee_payout.py --config config/ --out out/,整条链跑出来的结果(同机相对量级,绝对数值不可移植):

环节值说明
毛收入50000 分源币 500.00 美元
平台分成后35000 分× (1000−300)/1000,砍掉三成
换汇后(CNY)252000 分× 7200000/1000000 = 2520.00 元

方式对照(同目标币 CNY):

方式换汇后手续费净到账周期
银行电汇2520001800023400030 天
PayPal252000504024696014 天
Payoneer25200037802482207 天

币种对照(同方式 Payoneer):

目标币汇率比换汇后手续费净到账
CNY72000002520003780248220
EUR9200003220048331717
JPY15700005495082454126
GBP7800002730040926891

摘要(可直接用于回归比对):

50000:35000:CNY:bank_wire:252000:18000:234000:30,payoneer:252000:3780:248220:7,paypal:252000:5040:246960:14|CNY:252000:3780:248220,EUR:32200:483:31717,JPY:54950:824:54126,GBP:27300:409:26891

方式对照 vs 币种对照

两张表回答的是两个不同的问题。方式对照问的是「同一笔钱,走哪个通道到手最多、最快」:Payoneer 净到账 248220 分、7 天到账,同时赢下金额和速度两项;PayPal 净 246960 分、14 天;电汇净 234000 分、30 天,因为固定费 18000 分(180 元)对这笔金额太重。这不是说电汇永远最差——固定费对大额会摊薄,Payoneer 的比例费对大额反而更贵,交叉点取决于单笔金额。

币种对照问的是「同一笔钱,换成哪种币到手最多」——但这里的「多」是不可直接比较的,因为 248220 人民币分、31717 欧分、54126 日元、26891 便士是四种不同的货币单位,数值大小毫无可比性。这张表真正的用途是检查小数位与最小单位处理是否正确:日元最小单位是 1 円(没有小数),人民币/欧元/英镑最小单位都是 100,若你的系统按「元」用浮点处理,日元这种无小数币种最容易在换算时丢掉尾数。四个目标币的换汇结果都由同一段整数代码算出,这正是「一份逻辑、多种消费」在结算侧的体现。

为什么必须纯整数

回到开头那条纪律。若把汇率写成浮点 7.2,35000 × 7.2 在某些机器上会得到 252000.00000000003 或 251999.99999999997,Math.floor 之后就是 252000 或 251999——同一笔钱,换台机器、换个运行时,可能差一分。成千上万笔结算累积起来,就是一笔对不平的账。本节用百万分比整数 7200000 承载汇率,换汇是「整数乘、整数除、地板除一次」,结果与语言、平台、CPU 无关。同理,手续费千分比、周期天数全是整数。

这些数不能靠命令行复现(X)

汇率表与手续费表是**「公开可观察机制」的建模假设**,不是权威数据源:真实汇率逐秒变动,rateMillionths 只是把某个观察时点(observedAt: 2026-10)的近似关系冻结成整数;真实手续费、真实到账周期、真实到账表现与各通道的实际扣费口径都需要真实业务环境,命令行无法复现,标为 X。真正能复现的,是给定这张表之后的换汇与扣费计算——那部分本节的整数模型逐笔可复算。

自检表

检查项合格标准
是否用浮点承载汇率否,百万分比整数比,换算地板除一次
手续费口径整数千分比 + 固定费,无小数
周期口径整数天数
顺序正确分成在换汇之前,换汇在手续费之前
方式对照同目标币下三种方式可比
币种对照明确标注不可跨币种比大小,只验最小单位处理
边界清晰真实汇率 / 手续费 / 到账标 X,不写成「实测」

本节把「到账金额」变成了一条可复算的整数链:分成、换汇、手续费、周期每一环都有数。但链条最后一环还有个绕不开的合规动作——填对税务表单。下一节处理 W-8BEN 等表单的工程落点:谁该填哪张表、预扣多少、用什么申报表,并明确划出「这不是税务意见」的边界。

阅读导航:上一节:11.1 平台收款方式与门槛 · 下一节:11.3 税务表单与合规(W-8BEN 等) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制