《移动游戏产品的架构·商业化与发行工程》7.3 买量 ROI 与回收周期

D1 35%、D30 82%、D180 132% 是三条不同的决策依据,不是同一个 ROI。本节以《潮汐消消》CPI ¥3.5 与《潮汐方舟》CPI ¥28 为背景,把回收曲线、回收点、边际 ROI 与放量阈值拆成可运行判断,说明 D30 未回本为什么仍可能值得继续投放。

三个 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 支付作弊与退款滥用 。

常见错误与边界

  1. 拿本月收入除本月花费。分子是存量用户的收入,分母是新增用户的成本,两个 cohort 对不上;ROI 必须按安装批次统计。
  2. 只算内购不算广告。消消广告占月流水 65%,漏掉广告收入会让 ROI 只剩三成左右;混合变现产品必须把两类收入都计入。
  3. 用 D1 ROI 决定长期放量。消消 D1 35%、方舟 D1 18%,按 D1 砍量会错过 D180 的 132% 和 110%。
  4. 把插值回收点当承诺。D53.5 和 D139.1 是两点线性估计,不是观测事实;对外承诺会制造无法兑现的预期。
  5. 对曲线外做外推。D365 没有观测点,外推出来的数字是模型输出,不是回收事实。
  6. 用平均 ROI 代替边际 ROI。平均好看不代表下一笔钱能回收;放量决策必须看增量。
  7. 花费没有增加也算边际。分母为零时返回 0 会被误读成不赚不亏;应返回空值并要求补数据。
  8. 把 ROI 当净利润。ROI 只扣买量成本,未扣商店分成、服务器、人力和税费;132% 不等于公司净赚 32%。
  9. 不同产品共用一套阈值。消消和方舟的回收周期差了两个多月,同一 D30 门槛会系统性误判其中一款。
  10. 回收点达标就无限放量。放量会改变边际成本,必须持续监控边际 ROI,而不是一次判定后长期执行。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查