三个 ROI 数字,三种用途
买量复盘会上经常出现这样的对话:投放说 D7 ROI 已经 55%,财务说 D30 才 82% 还没回本,老板问到底赚不赚钱。三个人说的是同一款产品,却像在说三件事。原因是 ROI 不是一个数,而是一条随时间变化的累计曲线。
| 观察点 | 回答的问题 | 主要用途 | 典型误用 |
|---|---|---|---|
| D1 / D7 | 素材和首日体验是否成立 | 停掉明显跑不动的计划 | 用 D1 判断长期回收 |
| D30 | 渠道质量是否稳定 | 渠道间横向比较 | 用 D30 未回本直接判死刑 |
| D60 / D90 | 回收是否进入正区间 | 决定是否继续放量 | 忽略后续运营成本 |
| D180 | 长线价值是否兑现 | 年度预算与产品评估 | 把长周期当短期承诺 |
案例里的两条曲线形状完全不同。《潮汐消消》D1 35%、D7 55%、D30 82%、D60 105%、D90 118%、D180 132%;《潮汐方舟》D1 18%、D7 28%、D30 62%、D90 88%、D180 110%。消消在 D60 前后跨过 100%,方舟要到 D90 之后、D180 之前才跨过。同一个「ROI」标签下,前者是两个月回本,后者是四五个月回本。
ROI 口径:累计收入除以买量成本
本节使用的 ROI 是同一批安装的累计收入除以这批安装的买量成本:
ROI(day) = 该 cohort 截至第 day 天的累计收入 / 该 cohort 的买量花费
三个限定词都不能省。同一批安装意味着按 cohort 统计,不能拿本月收入除本月花费;累计收入意味着内购与广告都要算,消消的广告占月流水 65%,漏掉广告,ROI 会只剩三成左右;买量花费意味着只除买量成本,自然量收入不进入分母,也不进入分子。
按这个口径,消消 D180 的 132% 表示每 100 元买量成本累计收回 132 元,毛利 32 元;方舟 D180 的 110% 表示收回 110 元,毛利 10 元。这里的「毛利」只做了买量成本与收入的简单相减,尚未扣商店分成、服务器、人力和税费,不能当成净利润。ROI 只回答「买量这笔钱有没有收回来」,不回答「公司整体赚不赚钱」。
把 ROI 换算成「每次安装收入」更直观:CPI × ROI。消消 CPI ¥3.5,D30 的每次安装累计收入约 ¥2.87,D180 约 ¥4.62;方舟 CPI ¥28,D30 约 ¥17.36,D180 约 ¥30.80。这组数字可以直接和商品客单价、ARPPU 对照,检查回收曲线是否合理。
回收点:100% 的那一天
回收点是累计 ROI 第一次达到 100% 的时间。它比「D30 ROI」更适合做预算排期,因为它直接回答「这笔钱什么时候回来」。
| 产品 | 跨过 100% 的区间 | 线性插值回收点 | 读法 |
|---|---|---|---|
| 消消 | D30 82% 到 D60 105% | 约 D53.5 | 两个月内回本,可以按季度滚动投放 |
| 方舟 | D90 88% 到 D180 110% | 约 D139.1 | 回本落在两个观测点之间,排期要留足现金 |
插值不是事实。案例只给了离散观测点,D53.5 和 D139.1 是假设两点之间线性变化得到的估计值。真实回收曲线通常前快后慢,线性插值会高估中段收入、低估后段收入。它的用途是排预算和设阈值,不是对外承诺「第 54 天一定回本」。
方舟的回收点尤其不能用插值当结论。D90 88% 到 D180 110% 之间隔了三个月,中间可能有一次大版本、一次活动或者一次买量结构变化;在没有更细观测点的情况下,只能说「回本发生在 D90 之后」,不能说「第 139 天回本」。工程上应该把 D120、D150 也纳入采集,而不是用两点插值补出一个看似精确的数。
边际 ROI:放量看增量,不看平均
平均 ROI 回答「过去这批钱赚没赚」,边际 ROI 回答「下一笔钱还赚不赚」。两者可能完全不同:一个渠道便宜的量先跑完,后面加量的价格明显上升,平均 ROI 仍然好看,但边际 ROI 已经低于 100%。
边际 ROI = (新增收入 - 原收入) / (新增花费 - 原花费)
代码用一组明确标注为假设的阶梯数据演示这条公式:花费从 1,000 元加到 1,470 元,收入从 900 元加到 1,200 元,边际 ROI 是 63.83%。这组数字不是案例投放数据,只用于验证「增量除增量」的计算;案例设定没有提供分档花费与分档收入,因此正文不给出消消或方舟的边际 ROI 具体值。
放量决策要同时看边际 ROI 和回收点:
| 条件 | 动作 | 理由 |
|---|---|---|
| 边际 ROI < 100% | 收缩 | 新增花费本身不回收,放得越多亏得越多 |
| 边际 ROI 缺失 | 观察 | 没有增量数据,不能判断下一笔钱的效果 |
| 回收点晚于目标 | 观察 | 即使边际 ROI 尚可,现金占用也超过预算容忍度 |
| 边际 ROI 高于阈值且回收点达标 | 放量 | 增量能回收,且回收速度在预算内 |
这里的「阈值」不是 100%,而是团队愿意接受的最低增量回报。本节的决策函数把 minMarginalRoiPct 单独作为参数,就是为了让业务把「覆盖资金成本与风险」的要求写进配置,而不是硬编码在代码里。
双案例:早回本与慢回本的两种节奏
《潮汐消消》CPI ¥3.5、D30 ROI 82%、D60 105%,回收点约在 D54;广告占月流水 65%,回收曲线对广告 eCPM 和填充率更敏感,D30 之前不宜因为「还没回本」就砍量。
《潮汐方舟》CPI ¥28、D30 62%、D90 88%、D180 110%,回收点落在 D90 之后;内购占月流水 97%,ARPPU ¥420,单用户价值高但回收慢,预算要按半年周期安排。
把回收曲线写成可运行的判断
消消的风险是「回本快但天花板低」,方舟的风险是「单用户价值高但现金占用久」。两者不能用同一个 D30 阈值管理,也不能用同一个放量节奏,所以判定规则必须参数化,而不是写死在报表里。
下面的实现把曲线校验、任意时点 ROI、回收点、单安装收入、边际 ROI 与放量决策拆成独立函数。所有函数只接受数字和数组,不读取当前日期,也不访问报表。
// file: src/biz/roi.ts
// tier: L1
// verify: node --test test/biz/roi.test.ts
export interface RoiPoint {
readonly day: number;
readonly roiPct: number;
}
export interface Cohort {
readonly cohortId: string;
readonly cpiCents: number;
readonly curve: readonly RoiPoint[];
}
export type CurveCode = "ok" | "empty" | "bad-day" | "bad-roi";
export interface CurveCheck {
readonly ok: boolean;
readonly code: CurveCode;
readonly detail: string;
}
export function validateCurve(curve: readonly RoiPoint[]): CurveCheck {
if (curve.length === 0) {
return { ok: false, code: "empty", detail: "curve has no points" };
}
let previousDay = -1;
for (const point of curve) {
if (!Number.isFinite(point.day) || point.day < 0 || point.day <= previousDay) {
return {
ok: false,
code: "bad-day",
detail: `day ${point.day} must be finite, non-negative and strictly increasing`,
};
}
if (!Number.isFinite(point.roiPct) || point.roiPct < 0) {
return { ok: false, code: "bad-roi", detail: `roi ${point.roiPct} must be non-negative` };
}
previousDay = point.day;
}
return { ok: true, code: "ok", detail: `${curve.length} points checked` };
}
export function roiAt(curve: readonly RoiPoint[], day: number): number | null {
if (!validateCurve(curve).ok || curve.length === 0) {
return null;
}
const first = curve[0] as RoiPoint;
const last = curve[curve.length - 1] as RoiPoint;
if (day < first.day || day > last.day) {
return null;
}
for (let index = 0; index < curve.length; index += 1) {
const point = curve[index] as RoiPoint;
if (point.day === day) {
return point.roiPct;
}
const next = curve[index + 1];
if (next !== undefined && day > point.day && day < next.day) {
const ratio = (day - point.day) / (next.day - point.day);
return round2(point.roiPct + ratio * (next.roiPct - point.roiPct));
}
}
return null;
}
export function paybackDay(curve: readonly RoiPoint[]): number | null {
if (!validateCurve(curve).ok || curve.length === 0) {
return null;
}
for (let index = 0; index < curve.length; index += 1) {
const point = curve[index] as RoiPoint;
if (point.roiPct < 100) {
continue;
}
const previous = curve[index - 1];
if (previous === undefined) {
return point.day;
}
if (point.roiPct === previous.roiPct) {
return point.day;
}
const ratio = (100 - previous.roiPct) / (point.roiPct - previous.roiPct);
return round2(previous.day + ratio * (point.day - previous.day));
}
return null;
}
export function revenuePerInstallCents(cpiCents: number, roiPct: number): number {
return Math.round((cpiCents * roiPct) / 100);
}
export function marginalRoiPct(
previousSpendCents: number,
previousRevenueCents: number,
nextSpendCents: number,
nextRevenueCents: number,
): number | null {
const spendDelta = nextSpendCents - previousSpendCents;
if (spendDelta <= 0) {
return null;
}
const revenueDelta = nextRevenueCents - previousRevenueCents;
return round2((revenueDelta / spendDelta) * 100);
}
export interface ScaleInput {
readonly marginalRoiPct: number | null;
readonly paybackDay: number | null;
readonly targetPaybackDay: number;
readonly minMarginalRoiPct: number;
}
export type ScaleAction = "scale" | "hold" | "cut";
export interface ScaleDecision {
readonly action: ScaleAction;
readonly reason: string;
}
export function decideScale(input: ScaleInput): ScaleDecision {
if (input.marginalRoiPct === null) {
return { action: "hold", reason: "insufficient-increment-data" };
}
if (input.marginalRoiPct < 100) {
return { action: "cut", reason: "marginal-roi-below-cost" };
}
if (input.paybackDay === null) {
return { action: "hold", reason: "no-payback-in-curve" };
}
if (input.paybackDay > input.targetPaybackDay) {
return { action: "hold", reason: "payback-beyond-target" };
}
if (input.marginalRoiPct < input.minMarginalRoiPct) {
return { action: "hold", reason: "marginal-roi-below-threshold" };
}
return { action: "scale", reason: "payback-and-marginal-roi-within-target" };
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
export function formatRoi(cohort: Cohort, day: number): string {
const roi = roiAt(cohort.curve, day);
const payback = paybackDay(cohort.curve);
const roiText = roi === null ? "n/a" : `${roi}%`;
const paybackText = payback === null ? "n/a" : `D${payback}`;
const rpi =
roi === null ? "n/a" : `¥${(revenuePerInstallCents(cohort.cpiCents, roi) / 100).toFixed(2)}`;
return `${cohort.cohortId} D${day} roi=${roiText} payback=${paybackText} rpi=${rpi}`;
}
代码里的判定顺序
validateCurve 要求观测点非空、day 严格递增、ROI 非负。曲线是外部报表导出的,脏数据比异常更常见;先校验再计算,可以避免 NaN 沿着插值一路传播。
roiAt 只做区间内插值,不外推。D0 或超过最后一个观测点的查询返回 null,而不是拿最近点或直线延长。外推会让「D365 ROI」这类没有观测支撑的数字看起来像事实,是回收讨论里最常见的假精确。
paybackDay 找第一个 ROI 大于等于 100% 的观测点,再与前一个点做线性插值。曲线始终不到 100% 时返回 null,调用方必须显式处理「未回本」,不能把它当成 0 或无穷大。
revenuePerInstallCents 把 CPI 与 ROI 相乘,输出以分为单位的每次安装累计收入。它只做换算,不判断这个收入是否健康;健康判断属于 decideScale。
marginalRoiPct 的分母是花费增量,分母小于等于零时返回 null。花费没有增加,就谈不上边际;返回 0 会被误读成「不赚不亏」。
decideScale 的顺序是:先看边际是否缺失,再看边际是否低于 100%,再看回收点是否存在、是否超过目标,最后看边际是否达到团队阈值。先判边际低于成本,是因为这种情况下无论回收点多早,继续放量都会扩大亏损。
formatRoi 输出一行稳定文本,包含 cohort、查询日、ROI、回收点和单安装收入。它用于日报和排查,不用于财务结算。
取舍:追 D1 会砍掉长线产品
如果团队用 D1 ROI 做唯一的放量门槛,消消的 35% 和方舟的 18% 都会被判为不合格。但消消 D180 是 132%,方舟 D180 是 110%,两者长期都能回收。用 D1 砍量,等于用首日表现否定整个长尾,这对中重度产品尤其致命。
反过来,只看 D180 也不行。等到第 180 天再决定是否继续投放,现金已经压了半年,错过的调整窗口无法追回。案例的做法是「用回收点管预算,用边际 ROI 管增量」:回收点在目标内才放量,边际 ROI 低于成本就收缩,D1 只用来停掉明显跑不动的素材。
这套做法牺牲的是短期报表的好看程度。按回收点管理,D30 尚未回本的计划不会被立刻砍掉,报表上会有一段「看似亏损」的时期;按边际 ROI 管理,放量速度会比「只看平均 ROI」更慢。换来的是决策与现金流一致,不会因为口径切换在季度末突然发现预算超支。
小结
- ROI 是随天数变化的累计曲线,D1、D30、D180 回答不同问题,不能互相替代。
- 口径必须是同一 cohort 的累计收入除以买量成本;消消的广告收入占 65%,漏算会让 ROI 只剩三成左右。
- 回收点比单点 ROI 更适合排预算;插值只是估计,案例只支持「消消约 D54、方舟在 D90 之后」这种粒度。
- 放量看边际 ROI,不看平均 ROI;边际低于 100% 时,回收点再早也不应继续加钱。
- 用回收点管预算、用边际管增量、用 D1 停明显跑不动的素材,是三件不同的事。
阅读导航:上一节:7.2 归因窗口、去重与隐私限制 · 下一节:8.1 支付作弊与退款滥用 。
常见错误与边界
- 拿本月收入除本月花费。分子是存量用户的收入,分母是新增用户的成本,两个 cohort 对不上;ROI 必须按安装批次统计。
- 只算内购不算广告。消消广告占月流水 65%,漏掉广告收入会让 ROI 只剩三成左右;混合变现产品必须把两类收入都计入。
- 用 D1 ROI 决定长期放量。消消 D1 35%、方舟 D1 18%,按 D1 砍量会错过 D180 的 132% 和 110%。
- 把插值回收点当承诺。D53.5 和 D139.1 是两点线性估计,不是观测事实;对外承诺会制造无法兑现的预期。
- 对曲线外做外推。D365 没有观测点,外推出来的数字是模型输出,不是回收事实。
- 用平均 ROI 代替边际 ROI。平均好看不代表下一笔钱能回收;放量决策必须看增量。
- 花费没有增加也算边际。分母为零时返回 0 会被误读成不赚不亏;应返回空值并要求补数据。
- 把 ROI 当净利润。ROI 只扣买量成本,未扣商店分成、服务器、人力和税费;132% 不等于公司净赚 32%。
- 不同产品共用一套阈值。消消和方舟的回收周期差了两个多月,同一 D30 门槛会系统性误判其中一款。
- 回收点达标就无限放量。放量会改变边际成本,必须持续监控边际 ROI,而不是一次判定后长期执行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。