《独立开发者的商业指南·资本与退出》13.1 三卷指标矩阵

三卷 135 篇用到的指标散在收入、合同、估值与退出各章,本节把它们收成 16 项、三层的一张矩阵:现金层看赚多少,风险层看有多确定,资本层看能换成多少钱。三个案例放进同一张表后,缺口、不可比项与口径差异会立刻显形;代码把矩阵、缺口统计与可比案例判断写成可测试的函数。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

读完前 38 节,手里会有一堆数字:ARR、SDE、月流失、应收、逾期率、集中度、估值、倍数、税后所得、关停净值。它们分散在三个卷、十几个章节里,口径各自正确,放在一起却不一定能直接比较。

本节做最后一步整理:把 16 项指标放进同一张矩阵,按「现金、风险、资本」三层排列,让三个案例在同一列上对齐。对齐之后,哪些数字可以直接比、哪些不能比、哪些案例根本没有这个数,会一眼看出来。

为什么需要矩阵

单看每一项,三个案例都算得清楚。放在一起,问题才出现。

LedgerLite 的月流失是 1.5%,Tidecast 是买断制、没有月流失,Nord 是项目制、也没有月流失。如果表格里留空,读者会以为「没算」;如果强行填 0,又会被误读成「流失率为零,比 1.5% 更好」。正确做法是标成「不适用」,并且在缺口统计里单独列出来。

同样的问题出现在集中度上。LedgerLite 的前 5 家占 31%,Nord 的前 3 家占 48%,Tidecast 的玩家有 9,600 个、没有「前几家客户」的概念。三个数字都叫「集中度」,口径却分别是前 5 家、前 3 家和单平台依赖。

矩阵的第一个作用,不是排序,而是把「不可比」标出来。

三层:现金、风险、资本

16 项指标分三层,对应三卷的主线。

现金层回答「一年赚多少」:年收入、ARR、SDE、SDE 利润率、付费客户数、月流失率。这一层的数字来自卷一,口径是权责发生制下的年度流量。

风险层回答「这些钱有多确定」:集中度、经常性收入占比、平台依赖、应收余额、逾期率、坏账准备率。这一层的数字来自卷二,口径是合同与账期。

资本层回答「整门生意能换成多少钱」:基准估值、基准倍数、税后所得、关停净值。这一层的数字来自卷三,口径是存量与交易。

三层不是并列关系,而是层层收窄。现金层告诉你生意有多大,风险层告诉你这个大小能维持多久,资本层告诉你别人愿意为「维持得住的那部分」付多少钱。跳过任何一层,资本层的数字都会失真。

三个案例放进同一张表

把三个案例的 16 项指标填进矩阵,得到下面这张表。金额用美元,比例用百分比,缺口标「—」。

指标层LedgerLiteTidecastNord Studio
年收入现金240,240191,904154,320
ARR现金240,24000
SDE现金130,240121,17134,320
SDE 利润率现金54.2%63.1%22.2%
付费客户数现金2609,60090
月流失率现金1.5%——
集中度风险前 5 家 31%平台 80%前 3 家 48%
经常性收入占比风险100%0%27.3%
平台依赖风险0%80%0%
应收余额风险12,000011,264
逾期率风险6%0%8%
坏账准备率风险1%0%2%
基准估值资本768,768248,708154,440
基准倍数资本3.2× ARR1.8× 收入4.5× SDE
税后所得资本576,576186,531115,830
关停净值资本——−10,601

这张表里最值得看的不是数字,是三处「—」,以及几个真实为 0 的格子。

LedgerLite 没有关停净值,因为它没有做过关停测算;Tidecast 和 Nord 没有月流失,因为它们的收入形态里没有订阅。0 出现在 Tidecast 的 ARR 和经常性收入上,这个 0 是真实的:买断制收入不是经常性收入,不能按 ARR 倍数估值。

把 0 和「—」分开,是矩阵的基本纪律。0 是一个测量结果,— 是一个缺口;把缺口当 0 填,会得出「Nord 的月流失为零、比 LedgerLite 更稳」这种完全错误的结论。

四类可比与不可比

矩阵填完之后,可以做四种比较。

同层同口径,可以直接比。 三案例的 SDE 都在现金层,都是年度流量,130,240、121,171、34,320 可以直接排序;LedgerLite 的 SDE 最高,Nord 最低。

同层不同口径,比之前先换算。 集中度都在风险层,但一个是前 5 家、一个是平台依赖、一个是前 3 家;要比「谁更集中」,得先把它们换成同一口径,例如都换算成「最大单一收入来源占比」。LedgerLite 是 9%,Tidecast 是 80%,Nord 是 16% 的下限。

跨层不能直接比。 240,240 美元的年收入和 768,768 美元的估值不是同一个量,前者是流量、后者是存量;说「估值是收入的 3.2 倍」可以,说「估值比收入多 528,528 美元」没有意义。

缺口不参与比较。 LedgerLite 缺关停净值,不代表它的关停净值为零或为正;它只是没算。凡是缺口,先补算,再比较。

代码:把矩阵写成数据

下面的 metric-matrix.ts 把 16 项指标、三个案例和缺口统计写成数据与函数。buildMatrix 返回逐行结果,gapSummary 按案例汇总缺口,comparableCases 给出在所有指标上都有值的案例。

// file: src/capital/metric-matrix.ts
// tier: L1
// verify: node --test test/capital/metric-matrix.test.ts

export type Layer = "cash" | "risk" | "capital";
export type Unit = "cents" | "bps" | "count" | "ratio";

export interface Metric {
  id: string;
  layer: Layer;
  label: string;
  unit: Unit;
}

export interface CaseFigures {
  name: string;
  values: Record<string, number | null>;
}

export interface MatrixRow extends Metric {
  values: Record<string, number | null>;
  gaps: string[];
}

export interface MatrixResult {
  rows: MatrixRow[];
  layerCounts: Record<Layer, number>;
  gapCount: number;
}

export const METRICS: Metric[] = [
  { id: "revenue", layer: "cash", label: "年收入", unit: "cents" },
  { id: "arr", layer: "cash", label: "年度经常性收入", unit: "cents" },
  { id: "sde", layer: "cash", label: "卖方可支配收益", unit: "cents" },
  { id: "sdeMargin", layer: "cash", label: "SDE 利润率", unit: "bps" },
  { id: "customers", layer: "cash", label: "付费客户数", unit: "count" },
  { id: "monthlyChurn", layer: "cash", label: "月流失率", unit: "bps" },
  { id: "topConcentration", layer: "risk", label: "前 3 或前 5 家集中度", unit: "bps" },
  { id: "recurringShare", layer: "risk", label: "经常性收入占比", unit: "bps" },
  { id: "platformDependence", layer: "risk", label: "单一平台或渠道依赖", unit: "bps" },
  { id: "receivables", layer: "risk", label: "应收余额", unit: "cents" },
  { id: "overdueRate", layer: "risk", label: "应收逾期率", unit: "bps" },
  { id: "badDebtRate", layer: "risk", label: "坏账准备率", unit: "bps" },
  { id: "valuation", layer: "capital", label: "基准估值", unit: "cents" },
  { id: "multiple", layer: "capital", label: "基准倍数", unit: "bps" },
  { id: "afterTax", layer: "capital", label: "税后所得", unit: "cents" },
  { id: "shutdownNet", layer: "capital", label: "关停净值", unit: "cents" },
];

export const CASES: CaseFigures[] = [
  {
    name: "LedgerLite",
    values: {
      revenue: 24_024_000,
      arr: 24_024_000,
      sde: 13_024_000,
      sdeMargin: 5_421,
      customers: 260,
      monthlyChurn: 150,
      topConcentration: 3_100,
      recurringShare: 10_000,
      platformDependence: 0,
      receivables: 1_200_000,
      overdueRate: 600,
      badDebtRate: 100,
      valuation: 76_876_800,
      multiple: 320,
      afterTax: 57_657_600,
      shutdownNet: null,
    },
  },
  {
    name: "Tidecast",
    values: {
      revenue: 19_190_400,
      arr: 0,
      sde: 12_117_100,
      sdeMargin: 6_314,
      customers: 9_600,
      monthlyChurn: null,
      topConcentration: 0,
      recurringShare: 0,
      platformDependence: 8_000,
      receivables: 0,
      overdueRate: 0,
      badDebtRate: 0,
      valuation: 24_870_800,
      multiple: 180,
      afterTax: 18_653_100,
      shutdownNet: null,
    },
  },
  {
    name: "Nord Studio",
    values: {
      revenue: 15_432_000,
      arr: 0,
      sde: 3_432_000,
      sdeMargin: 2_224,
      customers: 90,
      monthlyChurn: null,
      topConcentration: 4_800,
      recurringShare: 2_730,
      platformDependence: 0,
      receivables: 1_126_400,
      overdueRate: 800,
      badDebtRate: 200,
      valuation: 15_444_000,
      multiple: 450,
      afterTax: 11_583_000,
      shutdownNet: -1_060_080,
    },
  },
];

export function derivedSdeMargin(revenue: number, sde: number): number {
  if (revenue <= 0) throw new Error("revenue must be positive");
  return Math.round((sde * 10_000) / revenue);
}

export function buildMatrix(cases: CaseFigures[] = CASES): MatrixResult {
  if (cases.length === 0) throw new Error("at least one case is required");
  const rows: MatrixRow[] = METRICS.map((metric) => {
    const values: Record<string, number | null> = {};
    const gaps: string[] = [];
    for (const item of cases) {
      const value = item.values[metric.id] ?? null;
      values[item.name] = value;
      if (value === null) gaps.push(item.name);
    }
    return { ...metric, values, gaps };
  });
  const layerCounts: Record<Layer, number> = {
    cash: METRICS.filter((m) => m.layer === "cash").length,
    risk: METRICS.filter((m) => m.layer === "risk").length,
    capital: METRICS.filter((m) => m.layer === "capital").length,
  };
  const gapCount = rows.reduce((sum, row) => sum + row.gaps.length, 0);
  return { rows, layerCounts, gapCount };
}

export function comparableCases(result: MatrixResult): string[] {
  const names = new Set<string>();
  for (const row of result.rows) {
    for (const [name, value] of Object.entries(row.values)) {
      if (value !== null) names.add(name);
    }
  }
  return [...names].sort();
}

export function gapSummary(result: MatrixResult): Record<string, string[]> {
  const summary: Record<string, string[]> = {};
  for (const row of result.rows) {
    for (const name of row.gaps) {
      if (!summary[name]) summary[name] = [];
      summary[name].push(row.id);
    }
  }
  return summary;
}

代码里有三个刻意的选择。

第一,用 null 表示缺口,不用 0。null 和 0 在业务上完全不同:一个是「没算」,一个是「算出来是零」。类型上 number | null 强制调用方处理这两种情况,gapSummary 才有意义。

第二,金额用整数美分,比例用基点。240,240 美元存成 24,024,000,1.5% 存成 150;跨章相加、相乘时不会出现 0.1 + 0.2 这类浮点误差。

第三,buildMatrix 不修改传入的案例对象。每一行都新建 values 和 gaps,多个矩阵可以同时存在而不互相污染。

跑一遍测试,会看到三组断言:16 项指标、三案例的 SDE 利润率与存储值一致、缺口总数是 4(LedgerLite 缺关停净值,Tidecast 缺月流失与关停净值,Nord 缺月流失)。

矩阵怎么用

矩阵不是给读者看的报表,是给自己做决策的检查表。

第一,先看现金层。如果 SDE 利润率低于 25%,说明生意本身的盈利能力不够,谈估值没有意义;Nord 的 22.2% 就属于这一类。

第二,再看风险层。如果最大单一来源占比超过 30%,或者平台依赖超过 50%,资本层的倍数会被买方压低;Tidecast 的平台依赖 80%,所以它拿不到 ARR 倍数。

第三,最后看资本层。资本层的数字必须能追到现金层和风险层的假设;768,768 美元的估值背后是 240,240 美元的 ARR、1.5% 的流失和 31% 的前五大集中度,任何一项变化,估值都要重算。

第四,检查缺口。四个缺口分两类:两处月流失(Tidecast、Nord)是口径不适用,订阅制之外没有这个指标;两处关停净值(LedgerLite、Tidecast)是没算,应该补算。前者可以保留为空,后者是待办事项。

口径核对

矩阵里的每一列都必须能回到附录 B 的事实表。金额是否含税、比例的分母是什么、客户数是否含免费用户,这些细节决定两个数字能不能放在同一行。

三个案例的口径差异要记住:LedgerLite 的 ARR 等于年收入,因为全部是订阅;Tidecast 的年收入 191,904 美元是标价收入,净得 138,171 美元已经扣掉平台分成和退款;Nord 的年收入 154,320 美元含咨询与产品,产品部分才计经常性收入。

把这三个口径混在一起,会得出「Tidecast 的 SDE 利润率 63.1% 高于 LedgerLite 的 54.2%,所以 Tidecast 更赚钱」这种结论。实际上 Tidecast 的 SDE 利润率更高,是因为它没有雇员、没有销售费用,而不是因为生意质量更好;它的平台依赖和单一产品风险,在风险层已经扣过一轮。

小结

三卷 135 篇的指标,最终收敛到 16 项、三层。

现金层回答「赚多少」,风险层回答「有多确定」,资本层回答「能换多少钱」。三层依次收窄,任何一层缺失,下一层的数字都不可信。

矩阵的用处不是排序,而是暴露三件事:哪些数字可以直接比,哪些要先换算,哪些根本不存在。缺口用 null 标出,不用 0 冒充;0 是测量结果,缺口是待办事项。

下一节用这套矩阵给三个案例打健康度分,再把健康度与附录 C 的交易准备度放在一起,看每个案例下一步该做什么。

阅读导航:上一节:12.3 家族、合伙与继承 · 下一节:13.2 商业健康度评分 。

常见错误与边界

  1. 把缺口当 0。 Tidecast 没有月流失,不代表流失率为零;买断制的复购逻辑与订阅完全不同。
  2. 跨层直接比较。 年收入是流量,估值是存量;「估值比收入多多少」没有业务含义。
  3. 用标价收入算净得。 Tidecast 的 191,904 美元是标价,平台分成与退款之后才是 138,171 美元。
  4. 把 SDE 利润率当成生意质量。 没有雇员、没有销售费用的生意,利润率天然更高,但风险也更集中。
  5. 忽略口径差异。 集中度有的是前 5 家、有的是前 3 家、有的是平台依赖,换算之前不能排序。
  6. 只看资本层。 估值、倍数、税后所得都要回到现金层与风险层的假设,否则只是三个孤立的数字。

本节的边界是「指标整理」:它不重新计算任何原始数字,也不判断估值是否合理;它只保证同一张表上的数字口径一致、缺口可见。原始数字的来源与假设见附录 B,交易准备度的评分见附录 C。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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