站内游戏商业核心指标详解
是一份指标定义清单:ARPDAU、LTV、ROI 各是什么、怎么念。本节只做那篇没做的事——把定义落成一张可跑的对账台账,让跨章的账真的对得上。《上线变现与长线运营》
验收的是卷三的漏斗与收入链;本节换成卷四的账(投放花费、结算净额、单位账、月度账),并全部从共享 config/economy.json 复算,保证留存与产出曲线只有一个真源。
对账要解决的问题:同一科目,两条路径
上一节保证了手册结构自洽,但结构对不代表账对。前 15 章各写各的账,最危险的是口径分叉:第 5/6 章的收益按「千次展示收益」算、第 12 章的结算用千分比扣点、第 14 章的单位账又按「0.1 分」精度——同一个 ARPDAU 三处写出来可能不一样。
对账的办法很朴素:同一科目用两条相互独立的路径各算一遍,比残差。直接路径走本来的算法,反算路径从另一个方向绕回来;残差为 0 说明口径没分叉,超出容差就说明有一处写错了。八个科目与各自的两条路径见落盘 config/o16_recon.json(本节不重复展示其代码)。
S 层:对账台账仿真
下面这段是 S 层脚本 sim/o16_recon.py:读 config/o16_recon.json,从共享 config/economy.json 派生留存曲线与满级产出,算出八个科目的两条路径与残差,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o16_recon.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o16_recon.py --config config/ --out out/
"""全链路对账(S 层):把卷四各章指标拉到同一张台账,用共享 economy.json 复算,逐科目做双路径对账。
留存曲线 r(d)=1000*decay^d//100^d 与满级产出 maxOut=base*(num/den)^(maxLevel-1) 均取自共享
config/economy.json;每科目给「直接路径 vs 反算路径」残差与容差,超容差即判对不上。产出 JSON+CSV+SVG。
全整数、地板除、幂用大整数、无浮点。真实结算单/归因结果需真实环境,命令行不可复现(X)。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d, name):
with open(os.path.join(d, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def build(cfg, econ):
md, dau, iaa, iap = cfg["monthDays"], cfg["dau"], cfg["iaa"], cfg["iap"]
decay, horizon = econ["retention"]["decayPctPerDay"], econ["retention"]["horizonDays"]
curve = [1000 * decay ** d // 100 ** d for d in range(horizon)]
cum = sum(curve)
iaa_deci = iaa["ecpmFen"] * iaa["adRequestsPerDau"] * iaa["fillPct"] // 10000
iap_deci = iap["arppuFen"] * iap["payRatePermille"] // 100
arpdau = iaa_deci + iap_deci
d_iaa, d_iap = dau * iaa_deci // 10, dau * iap_deci // 10
daily = d_iaa + d_iap
m_iaa, m_iap = d_iaa * md, d_iap * md
gross = m_iaa + m_iap
net_iaa = m_iaa * (1000 - iaa["iaaSharePermille"]) // 1000
net_iap = m_iap * (1000 - iap["iapSharePermille"]) // 1000
withhold = (net_iaa + net_iap) * cfg["withholdPermille"] // 1000
net = net_iaa + net_iap - withhold
take = (gross - net) * 1000 // gross
cac = cfg["monthlySpendFen"] // cfg["monthlyInstalls"]
ltv_a = arpdau * cum // 1000
ltv_b = sum(arpdau * curve[d] // 1000 for d in range(horizon))
var, fixed = dau * cfg["variableCostPerDauFen"], cfg["fixedCostFen"]
cost = cfg["monthlySpendFen"] + var + fixed
# (id, 名称, 归属章, 直接路径, 反算路径, 容差)
accts = [
("dailyGross", "日毛收入(分)", "变现聚合(5/6章)", daily, d_iaa + d_iap, 0),
("arpdau", "单位 ARPDAU(0.1分)", "收入建模(14章)", arpdau, daily * 10 // dau, 0),
("monthlyGross", "月毛收入(分)", "结算(12章)", gross, m_iaa + m_iap, 0),
("cac", "单位 CAC(分)", "投放(2章)", cac, cfg["monthlySpendFen"] * 12 // (cfg["monthlyInstalls"] * 12), 0),
("netSettle", "月净结算(分)", "结算(12章)", net, gross * (1000 - take) // 1000, gross // 1000),
("ltv", "单位 LTV(0.1分)", "单位账(14章)", ltv_a, ltv_b, horizon),
("profit", "月经营利润(分)", "月度账(14章)", net - cost, net_iaa + net_iap - withhold - cost, 0),
("netArpdau", "单位净 ARPDAU(0.1分)", "单位账(14章)",
arpdau * (1000 - take) // 1000, net * 10 // (dau * md), 1),
]
rows = [dict(id=i, name=n, owner=o, direct=dd, reverse=rv, tolerance=t,
residual=dd - rv, ok=abs(dd - rv) <= t) for i, n, o, dd, rv, t in accts]
e = econ["economy"]
max_out = e["baseOutputPerMinute"] * (e["levelBonusNum"] ** (e["maxLevel"] - 1)) \
// (e["levelBonusDen"] ** (e["maxLevel"] - 1))
mono = all(curve[d] >= curve[d + 1] for d in range(horizon - 1))
return dict(rows=rows, cum=cum, take=take, cac=cac, ltv=ltv_a // 10, maxOut=max_out,
mono=mono, ok=all(r["ok"] for r in rows) and mono)
def svg(path, rows):
W, PAD, ROW, span, H = 760, 230, 34, 506, 74 + 34 * len(rows)
def sub(tag, **kw):
return ET.SubElement(root, tag, {k: str(v) for k, v in kw.items()})
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
sub("rect", width=W, height=H, fill="#ffffff")
sub("text", x=16, y=28, **{"font-size": 15, "fill": "#111827"}).text = "《合成小岛》全链路对账:各科目残差(相对容差)"
for i, a in enumerate(rows):
y, w = 52 + i * ROW, min(abs(a["residual"]) * span // ((a["tolerance"] or 1) * 4), span)
sub("text", x=16, y=y + 16, **{"font-size": 12, "fill": "#374151"}).text = a["name"]
sub("rect", x=PAD, y=y + 6, width=w, height=18, rx=3, fill="#16a34a" if a["ok"] else "#dc2626")
sub("text", x=PAD + w + 6, y=y + 19, **{"font-size": 11, "fill": "#6b7280"}).text = \
f'残差 {a["residual"]} / 容差 {a["tolerance"]}'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg, econ = load(a.config, "o16_recon"), load(a.config, "economy")
L = build(cfg, econ)
digest = ",".join(f'{r["id"]}:{r["direct"]}:{r["reverse"]}:{r["residual"]}' for r in L["rows"]) \
+ f';take={L["take"]},ltv={L["ltv"]},cac={L["cac"]},maxOut={L["maxOut"]},ok={str(L["ok"]).lower()}'
os.makedirs(a.out, exist_ok=True)
svg(os.path.join(a.out, "o16_recon.svg"), L["rows"])
cols = ("id", "name", "owner", "direct", "reverse", "residual", "tolerance", "ok")
with open(os.path.join(a.out, "o16_recon.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
w.writerows([r[k] for k in cols] for r in L["rows"])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"monthDays": cfg["monthDays"], "dau": cfg["dau"], "cumActivePermille": L["cum"],
"takePermille": L["take"], "cacFen": L["cac"], "ltvFen": L["ltv"],
"maxLevelOutput": L["maxOut"], "retentionMonotone": L["mono"], "accounts": L["rows"],
"failed": [r["id"] for r in L["rows"] if not r["ok"]], "ok": L["ok"], "digest": digest}
with open(os.path.join(a.out, "o16_recon.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
accts = ",".join(f'{r["id"]}:{r["residual"]}/{r["tolerance"]}' for r in L["rows"])
print(json.dumps({"accounts": accts, "takePermille": L["take"], "cacFen": L["cac"],
"ltvFen": L["ltv"], "cumActivePermille": L["cum"], "maxLevelOutput": L["maxOut"],
"failed": rep["failed"], "ok": L["ok"], "digest": digest},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机跑 for f in sim/o16_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,对账台账的真实输出(同机相对量级,绝对数值不可移植):
{
"accounts": "dailyGross:0/0,arpdau:0/0,monthlyGross:0/0,cac:0/0,netSettle:-2160/28080,ltv:17/30,profit:0/0,netArpdau:0/1",
"takePermille": 373,
"cacFen": 250,
"ltvFen": 596,
"cumActivePermille": 11463,
"maxLevelOutput": 170,
"failed": [],
"ok": true,
"digest": "dailyGross:936000:936000:0,arpdau:520:520:0,monthlyGross:28080000:28080000:0,cac:250:250:0,netSettle:17604000:17606160:-2160,ltv:5960:5943:17,profit:6154000:6154000:0,netArpdau:326:326:0;take=373,ltv=596,cac=250,maxOut=170,ok=true"
}
八个科目里六个残差恰好为 0;两个非零(netSettle 残差 −2160 分、ltv 残差 17)不是算错,而是口径本身的取整差,本节末尾解释为什么它们应当存在。
P 层:跨语言对账内核
一张台账你没法确认「是模型对」还是「两条路径碰巧都错」。下面这段是 P 层内核 core/src/o16/recon.ts,读同一份 config/o16_recon.json 与同一份共享 economy.json,用同样的整数地板除、同样的 BigInt 幂重算这八个科目,产出与 Python 逐字符一致的摘要。
// file: core/src/o16/recon.ts
// tier: P
// verify: node --test 'core/test/o16_*.test.ts'
//
// 全链路对账内核:与 sim/o16_recon.py 逐位等价的 TypeScript 对偶实现。读同一份 config/o16_recon.json
// 与共享 config/economy.json;全整数、地板除、幂用 BigInt、无浮点、无 Math.random。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface ReconConfig {
readonly version: number; readonly game: string; readonly monthDays: number; readonly dau: number;
readonly monthlyInstalls: number; readonly monthlySpendFen: number;
readonly variableCostPerDauFen: number; readonly fixedCostFen: number;
readonly iaa: { readonly ecpmFen: number; readonly adRequestsPerDau: number; readonly fillPct: number; readonly iaaSharePermille: number };
readonly iap: { readonly arppuFen: number; readonly payRatePermille: number; readonly iapSharePermille: number };
readonly withholdPermille: number;
}
export interface Economy {
readonly economy: { readonly baseOutputPerMinute: number; readonly levelBonusNum: number; readonly levelBonusDen: number; readonly maxLevel: number };
readonly retention: { readonly decayPctPerDay: number; readonly horizonDays: number };
}
export interface Account { readonly id: string; readonly name: string; readonly owner: string; readonly direct: number; readonly reverse: number; readonly tolerance: number; readonly residual: number; readonly ok: boolean; }
export interface Ledger { readonly curve: readonly number[]; readonly cumActive: number; readonly accounts: readonly Account[]; readonly takePermille: number; readonly ltvFen: number; readonly cacFen: number; readonly maxLevelOutput: number; readonly retentionMonotone: boolean; readonly ok: boolean; }
/** 由共享 economy 的 decayPctPerDay 派生留存曲线:r(d) = 1000*decay^d // 100^d。 */
export function retentionCurve(decayPct: number, days: number): number[] {
const out: number[] = [];
for (let d = 0; d < days; d += 1) out.push(Math.floor((1000 * decayPct ** d) / 100 ** d));
return out;
}
/** 满级每分钟产出:base*(num/den)^(maxLevel-1),幂用 BigInt、地板除。 */
export function maxLevelOutput(base: number, num: number, den: number, maxLevel: number): number {
const n = BigInt(num) ** BigInt(maxLevel - 1), d = BigInt(den) ** BigInt(maxLevel - 1);
return Number((BigInt(base) * n) / d);
}
export function buildLedger(cfg: ReconConfig, econ: Economy): Ledger {
const md = cfg.monthDays, dau = cfg.dau;
const curve = retentionCurve(econ.retention.decayPctPerDay, econ.retention.horizonDays);
const horizon = curve.length, cumActive = curve.reduce((s, r) => s + r, 0);
const iaaDeci = Math.floor((cfg.iaa.ecpmFen * cfg.iaa.adRequestsPerDau * cfg.iaa.fillPct) / 10000);
const iapDeci = Math.floor((cfg.iap.arppuFen * cfg.iap.payRatePermille) / 100);
const arpdau = iaaDeci + iapDeci;
const dIaa = Math.floor((dau * iaaDeci) / 10), dIap = Math.floor((dau * iapDeci) / 10);
const dailyGross = dIaa + dIap, mIaa = dIaa * md, mIap = dIap * md, monthlyGross = mIaa + mIap;
const netIaa = Math.floor((mIaa * (1000 - cfg.iaa.iaaSharePermille)) / 1000);
const netIap = Math.floor((mIap * (1000 - cfg.iap.iapSharePermille)) / 1000);
const withhold = Math.floor(((netIaa + netIap) * cfg.withholdPermille) / 1000);
const netSettle = netIaa + netIap - withhold;
const take = monthlyGross ? Math.floor(((monthlyGross - netSettle) * 1000) / monthlyGross) : 0;
const cac = Math.floor(cfg.monthlySpendFen / cfg.monthlyInstalls);
const ltvA = Math.floor((arpdau * cumActive) / 1000);
const ltvB = curve.reduce((s, r) => s + Math.floor((arpdau * r) / 1000), 0);
const varCost = dau * cfg.variableCostPerDauFen, fixed = cfg.fixedCostFen;
const raw = [
{ id: "dailyGross", name: "日毛收入(分)", owner: "变现聚合(第 5/6 章)", direct: dailyGross, reverse: dIaa + dIap, tolerance: 0 },
{ id: "arpdau", name: "单位 ARPDAU(0.1分)", owner: "收入建模(第 14 章)", direct: arpdau, reverse: Math.floor((dailyGross * 10) / dau), tolerance: 0 },
{ id: "monthlyGross", name: "月毛收入(分)", owner: "结算(第 12 章)", direct: monthlyGross, reverse: mIaa + mIap, tolerance: 0 },
{ id: "cac", name: "单位 CAC(分)", owner: "投放(第 2 章)", direct: cac, reverse: Math.floor((cfg.monthlySpendFen * 12) / (cfg.monthlyInstalls * 12)), tolerance: 0 },
{ id: "netSettle", name: "月净结算(分)", owner: "结算(第 12 章)", direct: netSettle, reverse: Math.floor((monthlyGross * (1000 - take)) / 1000), tolerance: Math.floor(monthlyGross / 1000) },
{ id: "ltv", name: "单位 LTV(0.1分)", owner: "单位账(第 14 章)", direct: ltvA, reverse: ltvB, tolerance: horizon },
{ id: "profit", name: "月经营利润(分)", owner: "月度账(第 14 章)", direct: netSettle - cfg.monthlySpendFen - varCost - fixed, reverse: netIaa + netIap - withhold - cfg.monthlySpendFen - varCost - fixed, tolerance: 0 },
{ id: "netArpdau", name: "单位净 ARPDAU(0.1分)", owner: "单位账(第 14 章)", direct: Math.floor((arpdau * (1000 - take)) / 1000), reverse: Math.floor((netSettle * 10) / (dau * md)), tolerance: 1 },
];
const accounts: Account[] = raw.map((a) => {
const residual = a.direct - a.reverse;
return { ...a, residual, ok: Math.abs(residual) <= a.tolerance };
});
const e = econ.economy;
const monotone = curve.every((r, d) => d === curve.length - 1 || r >= curve[d + 1]);
return { curve, cumActive, accounts, takePermille: take, ltvFen: Math.floor(ltvA / 10), cacFen: cac,
maxLevelOutput: maxLevelOutput(e.baseOutputPerMinute, e.levelBonusNum, e.levelBonusDen, e.maxLevel),
retentionMonotone: monotone, ok: accounts.every((a) => a.ok) && monotone };
}
/** 跨语言对拍摘要:与 sim/o16_recon.py 的 digest 逐字符一致。 */
export function digest(l: Ledger): string {
const per = l.accounts.map((a) => `${a.id}:${a.direct}:${a.reverse}:${a.residual}`).join(",");
return `${per};take=${l.takePermille},ltv=${l.ltvFen},cac=${l.cacFen},maxOut=${l.maxLevelOutput},ok=${l.ok}`;
}
配套测试 core/test/o16_recon.test.ts 把 Python 落盘的摘要写成金标准,跑 node --test 'core/test/o16_*.test.ts':
✔ 跨语言一致:TS 对账摘要 == Python 金标准 (0.703333ms)
✔ 共享 economy 派生:留存曲线单调不增、满级产出用 BigInt (0.116917ms)
✔ 逐渠道净额 == 综合扣点反推(在 1‰ 折叠容差内) (0.097083ms)
✔ 单位账自洽:CAC 250 分、LTV 596 分、覆盖 2.38 倍 (0.121667ms)
✔ LTV 两条取整路径:先和后除 vs 逐日取整,残差落在天数容差内 (0.100041ms)
✔ 整条台账对账通过:无科目超容差 (0.089208ms)
ℹ tests 6 ℹ pass 6 ℹ fail 0
Python 与 TypeScript 逐字符给出同一个摘要——两套实现没有口径分叉。
B 层:参数扰动下的对账稳健性
下面这段是 B 层基准 sim/o16_recon_bench.py:把六条驱动参数(eCPM、填充率、付费率、ARPPU、买量花费、安装数)逐个乘 pct//100,重跑同一台账,看恒等式科目会不会被破坏、利润会不会翻负。它是基准不是门禁:退出码恒为 0,只打软告警。
#!/usr/bin/env python3
# file: sim/o16_recon_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o16_recon_bench.py --config config/ --out out/
"""B 基准:对账台账对关键参数的稳健性(软告警不硬失败)。逐个把六条驱动参数乘 pct//100
(fillPct/payRatePermille 带上限截断),重跑 sim/o16_recon.py 的同一台账,观察是否破坏恒等式
科目或使月经营利润翻负。±15%(pct 85/115)带内触发即 WARN。退出码恒为 0。全整数、无浮点。"""
from __future__ import annotations
import argparse
import copy
import json
import os
from o16_recon import load, build
PCTS, WARN, CAPS = [70, 85, 100, 115, 130], {85, 115}, {"fillPct": 100, "payRatePermille": 1000}
PATHS = {"ecpmFen": ("iaa", "ecpmFen"), "fillPct": ("iaa", "fillPct"), "arppuFen": ("iap", "arppuFen"),
"payRatePermille": ("iap", "payRatePermille"), "monthlySpendFen": (None, "monthlySpendFen"),
"monthlyInstalls": (None, "monthlyInstalls")}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
base_cfg, econ = load(a.config, "o16_recon"), load(a.config, "economy")
base_profit = next(r["direct"] for r in build(base_cfg, econ)["rows"] if r["id"] == "profit")
cases, warns = [], []
for name, (grp, key) in PATHS.items():
for pct in PCTS:
cfg = copy.deepcopy(base_cfg)
raw = (cfg[grp][key] if grp else cfg[key]) * pct // 100
(cfg[grp] if grp else cfg)[key] = min(raw, CAPS[name]) if name in CAPS else raw
led = build(cfg, econ)
failed = [r["id"] for r in led["rows"] if not r["ok"]]
profit = next(r["direct"] for r in led["rows"] if r["id"] == "profit")
cases.append({"param": name, "pct": pct, "failed": failed, "profitFen": profit})
if pct in WARN and (failed or profit <= 0):
warns.append(f"{name} ±{abs(pct - 100)}%")
rep = {"source": "sim/o16_recon_bench.py", "baseProfitFen": base_profit, "perturbPcts": PCTS,
"cases": cases, "maxFailed": max(len(c["failed"]) for c in cases),
"minProfitFen": min(c["profitFen"] for c in cases), "warnings": warns,
"verdict": "PASS(soft)" if not warns else f"WARN(soft) {len(warns)} 条"}
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "o16_recon_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({k: rep[k] for k in ("baseProfitFen", "maxFailed", "minProfitFen",
"warnings", "verdict")}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真跑结果(同机相对量级,绝对数值不可移植):baseProfitFen=6154000、maxFailed=0、minProfitFen=2363200、warnings=[]、verdict=PASS(soft)。在 ±30% 的参数扫动里,八个科目的恒等式一条都没被破坏(maxFailed=0),月经营利润从基准 6154000 分最低掉到 2363200 分——始终为正。fillPct 乘到 115%/130% 时被上限 100 截断,故曲线右端走平;monthlyInstalls 对利润无影响(利润用的是买量花费而非安装数),这是口径正确的表现而非 bug。
对账结果解读
两个非零残差各有明确来由。netSettle 的 −2160 分:直接路径逐渠道按各自扣点算,反算路径把扣点折叠成一个综合扣点率 373‰ 再乘回总毛额——折叠本身就是加权平均,反推必然带 0.1‰ 量级误差;容差若设成 1 分,会把这条口径近似误报成对账失败,正确容差是毛额的 1‰(28080 分)。ltv 的 17:直接路径「先把 30 天留存加起来再除」,反算路径「逐日取整再累加」,30 次地板除最多丢 30 个单位,故容差取天数 30。这正呼应第 12 章那句「综合扣点是加权折叠出来的,不是各档简单相加」。
对账证明各章的账互相不打架,但账算对不等于东西交得出去。下一节把交付物清单拉出来,看这卷书到底缺了什么。
阅读导航:上一节:16.1 装配成一份操盘手册 · 下一节:16.3 交付物与卷五衔接 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。