站内《上线变现与长线运营》 把收入模型、LTV、回本算得很细,站内游戏商业核心指标详解 定义了 ARPDAU、LTV 这些指标的口径。但那些指标讲的都是利润表口径的「赚了多少」,本节要补的是现金流量表口径的「钱到了没有」。两者经常对不上,而对不上的那一段,正是把小团队拖垮的地方。分工很清楚:前两者回答「赚多少」,本节回答「什么时候能花」。
上一节我们算清了每个平台的综合扣点与账期。那一节停留在「一笔收入到手多少」的静态账。这一节把时间轴拉进来——同一笔钱,赚到的那天和到账的那天之间隔了几十天,这几十天里你要照常发工资、付服务器、投下一波量。这就是现金流问题。
收入确认 vs 现金到账:两个时点
会计上,收入在履约完成时确认——玩家看了广告、买了内购,这一天你就「赚到」了这笔钱,哪怕一分钱还没到账。这叫权责发生制。而现金在平台打款时才真正进账,这叫收付实现制。两个时点之间的差额,形成两样东西:
- 应收账款:已确认但未收到的钱,是资产,也是风险;
- 资金缺口:为维持运营需要垫付的现金,是压力。
对小团队来说,第二样更要命。你没有大公司的授信额度,缺口只能用自有现金或借款填。所以运营操盘手必须能回答:我放量到某个规模,最多要垫多少钱、垫多久。
资金缺口 = 每日净收入 × 账期
先把最简单的模型说清楚。假设每天稳定产生 dailyNet 的净收入,账期 lag 天,那么:
- 第 d 天确认的收入,第
d + lag天才到账; - 稳定生产时,任意一天的「已确认未收到」=
dailyNet × lag; - 这就是资金缺口的稳态值——也是你放量前必须先准备好的现金量。
这个乘法极其朴素,但它的推论不朴素:缺口和账期成正比,和规模也成正比。规模翻倍,缺口翻倍;账期从 30 天拉到 60 天,缺口再翻一倍。两个一起变,缺口四倍。这就是为什么「先小规模验证账期,再放量」是纪律,不是保守。
真实情况还要复杂一点:平台常留一笔风险金(暂扣),结算时先扣一部分,隔一段再释放。这等于在账期之外再加一段小账期。模型里要把它显式建出来,否则会低估缺口。
S 层仿真:缺口曲线
下面这段是 S 层仿真脚本 sim/o12_cashflow.py。它读 config/o12_cashflow.json 的四条流水线,逐日累计「确认收入」与「到账现金」两条曲线,找出资金缺口的峰值与出现日,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o12_cashflow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o12_cashflow.py --config config/ --out out/
"""现金流与收入确认仿真:把「收入确认的时点」与「现金到账的时点」拉开,算出资金缺口曲线。
读 config/o12_cashflow.json。每个流水线 s:
dailyNetFen(s) = dailyGrossFen * (1000 - sharePermille) // 1000 # 每日净收入
lag(s) = settleCycleDays + holdDays # 结算账期(天)
settleNetFen(s) = dailyNetFen * (1000 - reservePermille) // 1000 # 结算先扣风险金
reserveFen(s) = dailyNetFen - settleNetFen # 风险金(延后释放)
第 d 天(0 基):
recognized(d) = Σ_s dailyNetFen(s),当 d < activeDays(收入在赚到那天确认)
cash(d) = Σ_s settleNetFen(s),当 d-lag(s) 落在 [0, activeDays)
+ Σ_s reserveFen(s), 当 d-lag(s)-reserveReleaseDays 落在 [0, activeDays)
缺口(d) = Σ_{t<=d} recognized(t) - Σ_{t<=d} cash(t)
peakGapFen = max_d 缺口(d)(= 需要垫付的营运资金峰值);peakGapDay 为首次取到峰值的天。
口径:真实账期与暂扣规则需真实业务环境,命令行不可复现(X)。
产出 out/o12_cashflow.json + .csv + .svg。全整数、地板除,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "o12_cashflow.json"), encoding="utf-8") as f:
return json.load(f)
def stream_params(s: dict, reserve_permille: int) -> dict:
daily_net = s["dailyGrossFen"] * (1000 - s["sharePermille"]) // 1000
settle_net = daily_net * (1000 - reserve_permille) // 1000
return {"id": s["id"], "name": s["name"], "dailyNetFen": daily_net,
"lag": s["settleCycleDays"] + s["holdDays"], "settleNetFen": settle_net,
"reserveFen": daily_net - settle_net}
def simulate(cfg: dict) -> dict:
horizon = cfg["horizonDays"]
active = cfg["activeDays"]
reserve_rel = cfg["reserveReleaseDays"]
params = [stream_params(s, cfg["reservePermille"]) for s in cfg["streams"]]
daily_net_total = sum(p["dailyNetFen"] for p in params)
curve = []
cum_rec = cum_cash = 0
peak_gap, peak_day = 0, -1
for d in range(0, horizon + 1):
rec = daily_net_total if d < active else 0
cash = 0
for p in params:
if 0 <= d - p["lag"] < active:
cash += p["settleNetFen"]
if 0 <= d - p["lag"] - reserve_rel < active:
cash += p["reserveFen"]
cum_rec += rec
cum_cash += cash
gap = cum_rec - cum_cash
if gap > peak_gap:
peak_gap, peak_day = gap, d
curve.append({"day": d, "recognizedFen": rec, "cashFen": cash,
"cumRecognizedFen": cum_rec, "cumCashFen": cum_cash, "gapFen": gap})
return {"params": params, "dailyNetTotalFen": daily_net_total, "curve": curve,
"peakGapFen": peak_gap, "peakGapDay": peak_day,
"totalRecognizedFen": cum_rec, "totalCashFen": cum_cash,
"cashLags": sorted(p["lag"] for p in params),
"maxLag": max(p["lag"] for p in params)}
def draw_svg(path: str, curve: list, peak_day: int) -> int:
"""折线图:累计收入确认 vs 累计现金到账,中间阴影即资金缺口;峰值画竖线。"""
W, H, PAD_L, PAD_B, PAD_T, PAD_R = 760, 320, 60, 40, 30, 20
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": "20", "font-size": "14",
"fill": "#111827"}).text = "收入确认 vs 现金到账(累计,分):两线之差 = 资金缺口"
plot_w, plot_h = W - PAD_L - PAD_R, H - PAD_T - PAD_B
n = len(curve)
max_v = max(c["cumRecognizedFen"] for c in curve) or 1
x = lambda d: PAD_L + plot_w * d // max(1, n - 1)
y = lambda v: PAD_T + plot_h - plot_h * v // max_v
for c in curve:
if c["cumRecognizedFen"] > c["cumCashFen"]:
ET.SubElement(root, "rect", {"x": str(x(c["day"])), "y": str(y(c["cumRecognizedFen"])),
"width": "1", "height": str(y(c["cumCashFen"]) - y(c["cumRecognizedFen"])),
"fill": "#fde68a"})
rec_pts = " ".join(f'{x(c["day"])},{y(c["cumRecognizedFen"])}' for c in curve)
cash_pts = " ".join(f'{x(c["day"])},{y(c["cumCashFen"])}' for c in curve)
ET.SubElement(root, "polyline", {"points": rec_pts, "fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
ET.SubElement(root, "polyline", {"points": cash_pts, "fill": "none", "stroke": "#16a34a", "stroke-width": "2"})
ET.SubElement(root, "line", {"x1": str(x(peak_day)), "y1": str(PAD_T), "x2": str(x(peak_day)),
"y2": str(H - PAD_B), "stroke": "#ef4444", "stroke-dasharray": "4 4"})
ET.SubElement(root, "text", {"x": str(x(peak_day) + 4), "y": str(PAD_T + 12), "font-size": "11",
"fill": "#ef4444"}).text = f"缺口峰值 D{peak_day}"
ET.SubElement(root, "text", {"x": str(PAD_L), "y": str(H - 10), "font-size": "11",
"fill": "#2563eb"}).text = "蓝=累计确认 绿=累计到账 黄=缺口"
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))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
sim = simulate(cfg)
digest = f"dailyNet={sim['dailyNetTotalFen']},peakGap={sim['peakGapFen']},peakDay={sim['peakGapDay']}," \
f"totalRec={sim['totalRecognizedFen']},totalCash={sim['totalCashFen']}"
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o12_cashflow.svg"), sim["curve"], sim["peakGapDay"])
with open(os.path.join(a.out, "o12_cashflow.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["day", "recognizedFen", "cashFen", "cumRecognizedFen", "cumCashFen", "gapFen"])
for c in sim["curve"]:
w.writerow([c["day"], c["recognizedFen"], c["cashFen"], c["cumRecognizedFen"],
c["cumCashFen"], c["gapFen"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"horizonDays": cfg["horizonDays"], "activeDays": cfg["activeDays"],
"reservePermille": cfg["reservePermille"], "reserveReleaseDays": cfg["reserveReleaseDays"],
"streams": [{k: v for k, v in p.items()} for p in sim["params"]],
"dailyNetTotalFen": sim["dailyNetTotalFen"], "cashLags": sim["cashLags"],
"maxLag": sim["maxLag"], "peakGapFen": sim["peakGapFen"], "peakGapDay": sim["peakGapDay"],
"totalRecognizedFen": sim["totalRecognizedFen"], "totalCashFen": sim["totalCashFen"],
"digest": digest, "csv": os.path.join(a.out, "o12_cashflow.csv"),
"svg": os.path.join(a.out, "o12_cashflow.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o12_cashflow.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"dailyNetTotalFen": sim["dailyNetTotalFen"], "cashLags": sim["cashLags"],
"maxLag": sim["maxLag"], "peakGapFen": sim["peakGapFen"],
"peakGapDay": sim["peakGapDay"], "totalRecognizedFen": sim["totalRecognizedFen"],
"totalCashFen": sim["totalCashFen"],
"peakGapPerLagDayFen": sim["peakGapFen"] // max(1, sim["maxLag"]),
"digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o12_cashflow.json。四条流水线覆盖微信 / 抖音的广告与内购,账期不同,风险金按结算额 10% 暂扣、30 天后释放:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"horizonDays": 120,
"activeDays": 60,
"reservePermille": 100,
"reserveReleaseDays": 30,
"note": "现金流与收入确认参数。口径是「公开可观察机制」的建模假设,不是任何平台结算单实测。dailyGrossFen=每毛收入(分/日);sharePermille=平台扣点千分比;settleCycleDays=结算周期(天);holdDays=冻结/账期(天);reservePermille=平台风险金暂扣千分比(结算时先扣,reserveReleaseDays 天后释放)。收入在「赚到那天」确认,现金在「账期到那天」到账。真实账期与暂扣规则需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"streams": [
{ "id": "iaa_wx", "name": "广告(微信)", "dailyGrossFen": 150000, "sharePermille": 400, "settleCycleDays": 30, "holdDays": 15 },
{ "id": "iap_wx", "name": "内购(微信)", "dailyGrossFen": 70000, "sharePermille": 300, "settleCycleDays": 30, "holdDays": 15 },
{ "id": "iaa_dy", "name": "广告(抖音)", "dailyGrossFen": 90000, "sharePermille": 350, "settleCycleDays": 15, "holdDays": 7 },
{ "id": "iap_dy", "name": "内购(抖音)", "dailyGrossFen": 30000, "sharePermille": 300, "settleCycleDays": 15, "holdDays": 7 }
]
}
在本机跑 for f in sim/o12_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,结果是(同机相对量级,绝对数值不可移植):
| 指标 | 数值 |
|---|---|
| 每日净收入合计(分) | 218500 |
| 账期集合(天) | 22 / 45 |
| 最长账期(天) | 45 |
| 累计确认收入(分) | 13110000 |
| 累计到账现金(分) | 12915400 |
| 资金缺口峰值(分) | 8451000 |
| 缺口峰值出现日 | D59 |
读这张表要读出三件事。第一,确认了 1311 万,到账只有 1291.5 万——差额 194,600 分(约 1946 元)正是账期还没走完的风险金尾款,在 horizon 内还没释放完。第二,缺口峰值 8451000 分(约 8.45 万元)出现在 D59——不是月底,而是「收入停止增长、现金还没跟上」的那一刻。第三,峰值缺口 ÷ 最长账期 = 8451000 // 45 = 187800 分/天,也就是每多压一天账期,要多垫约 1878 元。这个数就是下一节 B 基准要盯的敏感度。
B 基准:缺口对账期有多敏感
缺口峰值完全由账期决定,而账期是平台条款,你单方面改不了。所以要问一个稳健性问题:「如果每个流水的账期整体 ±5 天,缺口峰值会怎么变?」下面这段是 B 层基准脚本 sim/o12_cashflow_bench.py,把每个流水的 settleCycleDays 整体平移 ±5 天重跑仿真,软告警不硬失败。
#!/usr/bin/env python3
# file: sim/o12_cashflow_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o12_cashflow_bench.py --config config/ --out out/
"""B 基准:资金缺口峰值对「结算账期」的敏感度。
现金流缺口峰值直接由账期决定——而账期是**平台条款**,不是你能单方面决定的常数。
所以要问:「如果每个流水的账期整体 ±N 天,缺口峰值会怎么变?每多压一天账期,要多垫多少钱?」
做法:把每个流水的 settleCycleDays 整体 ±N 天,重跑 simulate(),比较 peakGapFen。
软告警:任一方向缺口峰值变化超过 50% 即打 WARN。退出码恒为 0——这是基准,不是门禁。
全整数、地板除,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
from o12_cashflow import load, simulate
PERTURB_DAYS = 5
def shifted(cfg: dict, days: int) -> dict:
out = json.loads(json.dumps(cfg))
for s in out["streams"]:
s["settleCycleDays"] = max(0, s["settleCycleDays"] + days)
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
base = simulate(cfg)["peakGapFen"]
cases = []
for days in (-PERTURB_DAYS, PERTURB_DAYS):
sim = simulate(shifted(cfg, days))
delta = sim["peakGapFen"] - base
cases.append({"shiftDays": days, "peakGapFen": sim["peakGapFen"], "peakGapDay": sim["peakGapDay"],
"deltaFen": delta, "deltaPermille": delta * 1000 // base if base else 0})
span_days = 2 * PERTURB_DAYS
per_day = (cases[1]["peakGapFen"] - cases[0]["peakGapFen"]) // span_days
warn = any(abs(c["deltaPermille"]) > 500 for c in cases)
os.makedirs(a.out, exist_ok=True)
rep = {"version": cfg["version"], "source": "sim/o12_cashflow_bench.py", "perturbDays": PERTURB_DAYS,
"basePeakGapFen": base, "cases": cases, "gapPerLagDayFen": per_day,
"verdict": "WARN(soft) 缺口对账期高度敏感" if warn else "PASS(soft) 缺口对账期不敏感"}
with open(os.path.join(a.out, "o12_cashflow_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"basePeakGapFen": base, "cases": cases, "gapPerLagDayFen": per_day,
"verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,结果是 PASS(soft),退出码仍为 0:
| 扰动 | 缺口峰值(分) | 相对基准‰ |
|---|---|---|
| 账期 −5 天 | 7428000 | −122 |
| 基准 | 8451000 | 0 |
| 账期 +5 天 | 9474000 | +121 |
每压一天账期,缺口峰值变化 (9474000 − 7428000) // 10 = 204600 分/天,也就是每多压一天账期多垫约 2046 元。注意这个数比上面稳态估算的 1878 元/天大——因为它同时叠加了风险金释放期的平移。B 基准给出的结论是:缺口对账期近似线性敏感,但敏感度不低(5 天就动 12%)。所以账期谈判里,每争到一天都是真金白银的营运资金;反过来,接受一个更长的账期,等于默默多垫一笔现金。
X 层:真实账期与暂扣规则不可复现
边界必须划清。上面算的是模型侧:给定每日净收入与账期,缺口曲线长什么样。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的结算账期与打款日(平台规则、节假日顺延、对账差异都会挪动实际到账日);
- 真实的风险金比例与释放条件(触发条件、扣减情形由平台条款定义);
- 真实的现金消耗节奏(工资、服务器、投放预算的付款时点未必与收入对齐,实际缺口可能比模型更大)。
工程上正确的做法是:把可复现的现金流模型与不可复现的真实付款节奏分开——模型给出「最低限度要垫多少」的下界,真实账本再叠加费用侧的时间差。这也是第 14 章月度经营账要跟本节字段对齐的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 双时点 | 收入确认日与现金到账日分开建模 |
| 风险金 | 暂扣与释放期显式建出,不并入账期蒙混 |
| 峰值 | 给出缺口峰值金额与出现日,不只看总额 |
| 敏感度 | 对账期做 ±N 天扰动,量化每元/天 |
| 边界清晰 | 真实账期与暂扣规则标 X,不写成「实测」 |
现金流缺口算出来了,但它还停在「平台 → 你」这一段。真正落到银行账户之前,还有最后一道关:税、发票、跨境收款。下一节我们把这最后一段的净额算清楚。
阅读导航:上一节:12.1 平台分成与结算周期 · 下一节:12.3 税务、发票与跨境收款 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。