14.2 月度经营账与现金流

月度经营账把单用户账放大到一门按月运转的生意:逐月算「收入 − 变动成本 − 固定成本 = 营业利润」,再把现金按月滚一遍,给出营业利润转正月份、现金最低点与年度利润;用 Python 仿真跑满 12 个月,并用 B 基准检验转正月份对买量预算的敏感度。

站内《小游戏开发实战》卷三目录 把变现侧的模型算得很细,游戏商业核心指标详解 定义了 ARPDAU、LTV 这些口径,上一节我们又把单用户的 CAC 与 LTV 接成了单位账。但单位账回答的是「一个用户赚不赚」,它停在单点上;一门真实的生意是按月运转的——每个月都要发工资、付服务器、投下一波量,账上还得有现金撑着。本节不重做卷三 3 章的收入模型,也不重做 12.2 的「收入确认 vs 现金到账」时点差,而是把整月的经营账算出来:收入、变动成本、固定成本、营业利润,再把现金按月滚一遍,回答一个操盘手每月必须回答的问题——这个月我到底赚了没有,账上的钱还够不够。 分工是:卷三 3 章算「一个用户能赚多少」,12.2 算「一笔钱什么时候到账」,本节算「一个月这门生意是盈是亏、现金还剩多少」。

两张表:利润表与现金流量表

月度经营账其实是两张表叠在一起:

  • 利润表(P&L):这个月「赚了多少」。收入 − 变动成本 − 固定成本 = 营业利润。它看的是经营效率——生意本身成不成立。
  • 现金流量表:这个月「钱到没到」。期初现金 + 收进来的 − 付出去的 = 期末现金。它看的是生存能力——生意能不能撑到赚钱那天。

两张表经常对不上,而且转正月份与现金最低点常常不是同一个月。原因就是 12.2 讲过的时点差:这个月确认的收入,下个月才到账;但这个月的成本(工资、投放)当月就得付。于是哪怕某个月营业利润已经转正,现金可能还在往下掉——这正是把小团队拖垮的地方:账面赚钱,账上没钱。

月度账公式

把两张表写成整数公式(金额用「分」,比例用千分比):

netRevenue(m) = grossRevenueFen(m) × (1000 − sharePermille(m)) // 1000   # 扣平台后的净收入
opProfit(m)   = netRevenue(m) − uaSpendFen(m) − variableCostFen(m) − fixedCostFen(m)
cashIn(m)     = netRevenue(m − collectDelayMonths)                        # 收入延后到账(越界记 0)
cashOut(m)    = uaSpendFen(m) + variableCostFen(m) + fixedCostFen(m)      # 成本当月支付
cumCash(m)    = startCashFen + Σ_{k≤m} netCash(k)                         # 累计现金
cumProfit(m)  = Σ_{k≤m} opProfit(k)                                       # 累计营业利润

三个派生量决定这门生意的成败:breakEvenMonth(营业利润首次转正的月)、cashLowFen / cashLowMonth(现金最低点及其月份)、annualProfitFen(全年营业利润合计)。只看年度利润会漏掉现金最低点——一家公司可以全年盈利却因为某个月的现金断裂而死掉。

S 层仿真:逐月把账滚一遍

下面这段是 S 层仿真脚本 sim/o14_monthly.py。它读 config/o14_monthly.json 的 12 个月,逐月算营业利润与净现金,滚出累计现金与累计利润,找出转正月份、现金最低点与断裂月份,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o14_monthly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o14_monthly.py --config config/ --out out/
"""月度经营账与现金流:把「收入 - 变动成本 - 固定成本 = 营业利润」逐月算出来,再把现金滚一遍。

读 config/o14_monthly.json 的 12 个月。每个月:

    netRevenue(m) = grossRevenueFen(m) * (1000 - sharePermille(m)) // 1000   # 扣平台后的净收入
    opProfit(m)   = netRevenue(m) - uaSpendFen(m) - variableCostFen(m) - fixedCostFen(m)
    cashIn(m)     = netRevenue(m - collectDelayMonths)                       # 收入延后到账(越界记 0)
    cashOut(m)    = uaSpendFen(m) + variableCostFen(m) + fixedCostFen(m)     # 成本当月支付
    netCash(m)    = cashIn(m) - cashOut(m)
    cumCash(m)    = startCashFen + Σ_{k<=m} netCash(k)
    cumProfit(m)  = Σ_{k<=m} opProfit(k)

派生:breakEvenMonth(首个营业利润转正的月,否则 -1)、cashLowFen / cashLowMonth(现金最低点)、
runoutMonth(首个累计现金为负的月,否则 -1)、annualProfitFen(12 个月营业利润合计)。
注意营业利润转正的月份与现金最低点常常不在同一个月——这正是「利润表」与「现金流量表」的差。
全程整数、地板除,无浮点。产出 out/o14_monthly.json + .csv + .svg。
真实扣点、真实账期与真实成本结构需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations

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


def simulate(cfg: dict, ua_scale_permille: int = 1000) -> dict:
    delay = cfg["collectDelayMonths"]
    rows = []
    cum_cash = cfg["startCashFen"]
    cum_profit = 0
    break_even = -1
    cash_low, cash_low_month = cum_cash, 0
    runout = -1
    for i, mo in enumerate(cfg["months"]):
        ua = mo["uaSpendFen"] * ua_scale_permille // 1000
        net = mo["grossRevenueFen"] * (1000 - mo["sharePermille"]) // 1000
        op = net - ua - mo["variableCostFen"] - mo["fixedCostFen"]
        prev_net = cfg["months"][i - delay]["grossRevenueFen"] * \
            (1000 - cfg["months"][i - delay]["sharePermille"]) // 1000 if i - delay >= 0 else 0
        cash_out = ua + mo["variableCostFen"] + mo["fixedCostFen"]
        net_cash = prev_net - cash_out
        cum_cash += net_cash
        cum_profit += op
        if break_even < 0 and op > 0:
            break_even = mo["m"]
        if cum_cash < cash_low:
            cash_low, cash_low_month = cum_cash, mo["m"]
        if runout < 0 and cum_cash < 0:
            runout = mo["m"]
        rows.append({"m": mo["m"], "name": mo["name"], "netRevenueFen": net, "uaSpendFen": ua,
                     "variableCostFen": mo["variableCostFen"], "fixedCostFen": mo["fixedCostFen"],
                     "opProfitFen": op, "cashInFen": prev_net, "cashOutFen": cash_out,
                     "netCashFen": net_cash, "cumCashFen": cum_cash, "cumProfitFen": cum_profit})
    return {"rows": rows, "breakEvenMonth": break_even, "annualProfitFen": cum_profit,
            "cashLowFen": cash_low, "cashLowMonth": cash_low_month, "runoutMonth": runout,
            "finalCashFen": cum_cash}


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:逐月营业利润柱(正蓝负红)+ 累计现金折线,零线画横线。"""
    W, H, PAD_L, PAD_B, PAD_T, PAD_R = 760, 320, 56, 40, 30, 130
    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 = "《合成小岛》月度营业利润(柱)与累计现金(线)"
    plot_w, plot_h = W - PAD_L - PAD_R, H - PAD_T - PAD_B
    n = len(rows)
    lo = min([min(r["opProfitFen"], r["cumCashFen"]) for r in rows] + [0])
    hi = max([max(r["opProfitFen"], r["cumCashFen"]) for r in rows] + [1])
    span = hi - lo or 1
    y = lambda v: PAD_T + plot_h - plot_h * (v - lo) // span
    bw = plot_w // max(n, 1)
    zero = y(0)
    ET.SubElement(root, "line", {"x1": str(PAD_L), "y1": str(zero), "x2": str(W - PAD_R),
                                 "y2": str(zero), "stroke": "#9ca3af"})
    for i, r in enumerate(rows):
        x = PAD_L + i * bw
        v = r["opProfitFen"]
        top, bot = (y(v), zero) if v >= 0 else (zero, y(v))
        ET.SubElement(root, "rect", {"x": str(x + 2), "y": str(top), "width": str(bw - 4),
                                     "height": str(max(1, bot - top)),
                                     "fill": "#2563eb" if v >= 0 else "#ef4444"})
    pts = " ".join(f'{PAD_L + i * bw + bw // 2},{y(r["cumCashFen"])}' for i, r in enumerate(rows))
    ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": "#16a34a", "stroke-width": "2"})
    ET.SubElement(root, "text", {"x": str(PAD_L), "y": str(H - 10), "font-size": "11",
                                 "fill": "#2563eb"}).text = "蓝=月营业利润>0  红=亏损  绿=累计现金"
    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/")
    args = ap.parse_args()
    with open(os.path.join(args.config, "o14_monthly.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    sim = simulate(cfg)

    digest = (f"breakEven:{sim['breakEvenMonth']},annualProfit:{sim['annualProfitFen']},"
              f"cashLow:{sim['cashLowFen']},cashLowMonth:{sim['cashLowMonth']},"
              f"finalCash:{sim['finalCashFen']}")

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o14_monthly.svg"), sim["rows"])
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "startCashFen": cfg["startCashFen"], "collectDelayMonths": cfg["collectDelayMonths"],
              "months": sim["rows"], "breakEvenMonth": sim["breakEvenMonth"],
              "annualProfitFen": sim["annualProfitFen"], "cashLowFen": sim["cashLowFen"],
              "cashLowMonth": sim["cashLowMonth"], "runoutMonth": sim["runoutMonth"],
              "finalCashFen": sim["finalCashFen"], "digest": digest,
              "svg": os.path.join(args.out, "o14_monthly.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "o14_monthly.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o14_monthly.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("m", "name", "netRevenueFen", "uaSpendFen", "variableCostFen", "fixedCostFen",
                "opProfitFen", "cashInFen", "cashOutFen", "netCashFen", "cumCashFen", "cumProfitFen")
        w.writerow(cols)
        for r in sim["rows"]:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"breakEvenMonth": sim["breakEvenMonth"],
                      "annualProfitFen": sim["annualProfitFen"],
                      "cashLowFen": sim["cashLowFen"], "cashLowMonth": sim["cashLowMonth"],
                      "runoutMonth": sim["runoutMonth"], "finalCashFen": sim["finalCashFen"],
                      "opProfitByMonth": [r["opProfitFen"] for r in sim["rows"]],
                      "digest": digest}, ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/o14_monthly.json。12 个月覆盖从冷启动放量到稳定运营的全过程,扣点、账期、成本结构都是「公开可观察机制」口径的建模假设:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "startCashFen": 20000000,
  "collectDelayMonths": 1,
  "note": "月度经营账与现金流参数,口径是「公开可观察机制」的建模假设,不是任何真实账本。grossRevenueFen=当月毛收入(分);sharePermille=平台综合扣点千分比;uaSpendFen=当月买量支出(分);variableCostFen=变动成本(服务器/支付通道费,分);fixedCostFen=固定成本(团队/工具/资质摊销,分)。收入当月确认、collectDelayMonths 个月后到账;成本当月支付。startCashFen=期初现金。真实扣点、真实账期与真实成本结构需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "months": [
    { "m": 1,  "name": "2026-01", "grossRevenueFen": 6000000,  "sharePermille": 400, "uaSpendFen": 4000000, "variableCostFen": 400000,  "fixedCostFen": 2000000 },
    { "m": 2,  "name": "2026-02", "grossRevenueFen": 9000000,  "sharePermille": 400, "uaSpendFen": 5000000, "variableCostFen": 600000,  "fixedCostFen": 2000000 },
    { "m": 3,  "name": "2026-03", "grossRevenueFen": 14000000, "sharePermille": 400, "uaSpendFen": 6000000, "variableCostFen": 900000,  "fixedCostFen": 2000000 },
    { "m": 4,  "name": "2026-04", "grossRevenueFen": 20000000, "sharePermille": 400, "uaSpendFen": 7000000, "variableCostFen": 1300000, "fixedCostFen": 2000000 },
    { "m": 5,  "name": "2026-05", "grossRevenueFen": 26000000, "sharePermille": 400, "uaSpendFen": 8000000, "variableCostFen": 1700000, "fixedCostFen": 2200000 },
    { "m": 6,  "name": "2026-06", "grossRevenueFen": 32000000, "sharePermille": 400, "uaSpendFen": 9000000, "variableCostFen": 2100000, "fixedCostFen": 2200000 },
    { "m": 7,  "name": "2026-07", "grossRevenueFen": 36000000, "sharePermille": 400, "uaSpendFen": 9000000, "variableCostFen": 2400000, "fixedCostFen": 2200000 },
    { "m": 8,  "name": "2026-08", "grossRevenueFen": 38000000, "sharePermille": 400, "uaSpendFen": 8500000, "variableCostFen": 2500000, "fixedCostFen": 2200000 },
    { "m": 9,  "name": "2026-09", "grossRevenueFen": 38000000, "sharePermille": 400, "uaSpendFen": 8000000, "variableCostFen": 2500000, "fixedCostFen": 2200000 },
    { "m": 10, "name": "2026-10", "grossRevenueFen": 36000000, "sharePermille": 400, "uaSpendFen": 7000000, "variableCostFen": 2400000, "fixedCostFen": 2200000 },
    { "m": 11, "name": "2026-11", "grossRevenueFen": 34000000, "sharePermille": 400, "uaSpendFen": 6000000, "variableCostFen": 2300000, "fixedCostFen": 2200000 },
    { "m": 12, "name": "2026-12", "grossRevenueFen": 32000000, "sharePermille": 400, "uaSpendFen": 5500000, "variableCostFen": 2100000, "fixedCostFen": 2200000 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o14_monthly.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

指标数值
营业利润转正月份第 4 月
全年营业利润(分)62800000
现金最低点(分)4200000
现金最低点月份第 4 月
现金断裂月份无
期末现金(分)63600000

前三个月的营业利润是 −2800000 / −2200000 / −500000 分——冷启动期买量支出远高于收入,这是正常的,不是失败。第 4 月营业利润转正(+1700000 分),但现金最低点也恰好落在第 4 月(4200000 分,约 4.2 万元):因为确认的收入要下个月才到账,这个月的投放却要当月付。这就是「利润表」与「现金流量表」的差——账面第 4 月开始赚钱,账上第 4 月最危险。如果期初现金不是 2000 万分而是 300 万分,第 3 月就会现金断裂,生意直接死掉,哪怕它全年能赚 628000 元。

B 基准:转正月份对买量预算有多敏感

营业利润转正的月份几乎完全由买量支出决定,而买量预算是你唯一能主动拧的旋钮。所以要问一个稳健性问题:「如果每月买量支出整体 ±10%,转正月份、年度利润、现金低点会怎么变?」下面这段是 B 层基准脚本 sim/o14_monthly_bench.py,把每月 uaSpendFen 乘 900‰ / 1000‰ / 1100‰ 重跑仿真,软告警不硬失败。

#!/usr/bin/env python3
# file: sim/o14_monthly_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o14_monthly_bench.py --config config/ --out out/
"""B 基准:月度经营账对「买量预算」的敏感度。

营业利润转正的月份,几乎完全由买量支出决定——而买量预算是你唯一能主动拧的旋钮。
所以要问:「如果每月买量支出整体 ±10%,转正月份、年度利润、现金低点会怎么变?」
做法:把每月 uaSpendFen 整体乘 900‰ / 1000‰ / 1100‰,复用 simulate() 重跑,比较关键指标。
软告警:若 ±10% 之间转正月份漂移,或 +10% 时出现现金断裂(runout),打 WARN。退出码恒为 0。
全整数、地板除,无浮点。产出 out/o14_monthly_bench.json。
"""
from __future__ import annotations

import argparse
import json
import os

from o14_monthly import simulate

SCALES = (900, 1000, 1100)


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, "o14_monthly.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    base = simulate(cfg)
    cases = []
    for s in SCALES:
        sim = simulate(cfg, s)
        cases.append({"uaScalePermille": s, "breakEvenMonth": sim["breakEvenMonth"],
                      "annualProfitFen": sim["annualProfitFen"], "cashLowFen": sim["cashLowFen"],
                      "cashLowMonth": sim["cashLowMonth"], "runoutMonth": sim["runoutMonth"],
                      "finalCashFen": sim["finalCashFen"]})

    months = {c["breakEvenMonth"] for c in cases}
    runout = any(c["runoutMonth"] > 0 for c in cases)
    warn = (len(months) > 1) or runout

    os.makedirs(args.out, exist_ok=True)
    rep = {"version": cfg["version"], "source": "sim/o14_monthly_bench.py", "scales": list(SCALES),
           "baseBreakEvenMonth": base["breakEvenMonth"], "cases": cases,
           "distinctBreakEvenMonths": sorted(months), "runout": runout,
           "verdict": "WARN(soft) 经营账对买量预算敏感" if warn else "PASS(soft) 经营账对买量预算不敏感"}
    with open(os.path.join(args.out, "o14_monthly_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"baseBreakEvenMonth": base["breakEvenMonth"], "cases": cases,
                      "distinctBreakEvenMonths": sorted(months), "runout": runout,
                      "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
    return 0  # B 层软告警:恒返回 0


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

跑 /opt/homebrew/bin/python3 sim/o14_monthly_bench.py --config config/ --out out/,结果如下(退出码仍为 0):

买量预算转正月份年度利润(分)现金最低点(分)现金断裂
−10%第 3 月711000006400000无
基准第 4 月628000004200000无
+10%第 4 月545000001300000无

B 基准给出的结论是 WARN(soft):转正月份对买量预算敏感——预算降 10%,转正提前到第 3 月;预算升 10%,虽然转正月份不变,但现金最低点被压到 1300000 分(约 1.3 万元),几乎贴着零线。这解释了一个反直觉的纪律:加投不是越多越好。每多投 10% 买量,年度利润少约 830 万分,现金缓冲薄一层;投到某个点,现金低点就会击穿期初现金、引发断裂。所以「放量」必须先看现金最低点,而不是先看收入曲线——这也是 14.3 要据此做「止损还是加投」决策的原因。

X 层:真实扣点、账期与成本结构不可复现

上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的平台综合扣点(分成比例、渠道费、代扣税会叠加,且随渠道与品类不同);
  • 真实的结算账期与打款节奏(节假日顺延、对账差异、风险金暂扣都会挪动到账日,见 12.2);
  • 真实的成本结构(工资、服务器、买量的付款时点未必与收入对齐,实际现金低点可能更低)。

正确的用法是把这张月度账当预算与预警:模型给出「按当前节奏,第几月转正、现金最低点在哪」的基准线,每月用真实账本回填,一旦实际现金低于模型低点,就是该收缩买量的信号。

自检表

检查项合格标准
两张表齐利润表(营业利润)与现金流量表(累计现金)都算
时点差显式收入延后到账、成本当月支付,不混为一谈
转正月份给出 breakEvenMonth,不只看全年合计
现金最低点给出 cashLowFen 与月份,并判断裂月份
单位统一金额用「分」、比例用千分比,无浮点
敏感度对买量预算做 ±10% 扰动,量化转正与现金变化
边界清晰真实扣点与账期标 X,不写成「实测」

月度账告诉你「这门生意每个月能不能养活自己」。但账算出来只是第一步——真正难的是每个月据此做决策:哪些渠道该停、哪些该加、加多少。下一节把 14.1 的单位账与本节月度账合成一张决策表:止损与再投入。

阅读导航:上一节:14.1 单用户全链路账:CAC → ARPDAU → LTV → 回本 · 下一节:14.3 止损与再投入决策 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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