站内那篇独立游戏全球财务与多币种架构 把多币种账户、税务主体、结算通道这些结构性问题讲清楚了。本节不碰结构,只把其中最容易被浮点数坑掉的一段——从毛收入到净到账的那条算术链——做成一条可跑的整数模型。上一节(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):
| 方式 | 换汇后 | 手续费 | 净到账 | 周期 |
|---|---|---|---|---|
| 银行电汇 | 252000 | 18000 | 234000 | 30 天 |
| PayPal | 252000 | 5040 | 246960 | 14 天 |
| Payoneer | 252000 | 3780 | 248220 | 7 天 |
币种对照(同方式 Payoneer):
| 目标币 | 汇率比 | 换汇后 | 手续费 | 净到账 |
|---|---|---|---|---|
| CNY | 7200000 | 252000 | 3780 | 248220 |
| EUR | 920000 | 32200 | 483 | 31717 |
| JPY | 1570000 | 54950 | 824 | 54126 |
| GBP | 780000 | 27300 | 409 | 26891 |
摘要(可直接用于回归比对):
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 等) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。