《独立开发者的商业指南·收入与财务工程》5.3 预算偏差与季度校准

预算偏差不是好坏判断,而是方向、幅度和持续性的三件事。本节用 LedgerLite 的 Q1 新签缺口、Tidecast 的开发延期和 Nord Studio 的工时缺口,说明偏差率与绝对额两道阈值如何触发行动,以及滚动预测怎样把一次性波动和结构性变化分开。

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

预算偏差回答三个问题:差了多少、差得值不值得管、这个差会不会继续。第一个是算术,第二个是阈值,第三个是判断。很多预算表只做了第一个,于是每个月都在解释「为什么差了 0.8%」,却没有回答「下一季要不要改」。这一节把三件事拆开,并用三个案例走一遍季度校准。

偏差的方向不能只看正负。收入高于预算是好事,成本高于预算是坏事;同一个正数,在两张表里含义相反。先约定方向,再谈幅度,否则「偏差 +$2,550」会被误读成好消息。

偏差率与两道阈值

偏差的绝对额是实际减预算,偏差率是绝对额除以预算基数。收入与成本的方向约定如下:

方向实际高于预算实际低于预算
收入有利不利
成本不利,超支有利,节省

只有相对阈值不够。一个 2% 的偏差落在 $1,000,000 的收入上是 $20,000,落在 $10,000 的小科目上只有 $200;一个 20% 的偏差可能只值 $50,不值得开一次会。所以判定要同时看偏差率和绝对额,两条阈值取「或」:任意一条触发就升级。

级别触发条件默认动作
ok偏差率低于 5% 且绝对额低于 $1,000记录,不单独开会
watch偏差率至少 5%,或年化绝对额至少 $1,000解释原因,下一季复核
act偏差率至少 10%,或绝对额至少 $2,500改预算、改排期或改资源

5%、10%、$1,000、$2,500 都是教学参数,不是行业标准。它们的作用是让「什么值得管」有一个事先约定,而不是每次凭感觉。参数可以按公司规模调整,但要在年初写进预算说明,不能等偏差出现后再改。

季度偏差还要做年化。Q1 差 $468 看起来很小,如果同样的缺口每个季度都出现,全年就是 $1,872;年化把「一次性」和「会重复」区分开。年化不是预测,只是把当前速度外推,用来判断是否越过绝对额阈值。

LedgerLite:Q1 少签两家,年化后是 $1,872

LedgerLite 的 Q1 收入预算按当前运行率算是 3 × $20,020 = $60,060。下面假设 Q1 实际每月新签 8 家而不是预算的 10 家,其他桥接项——扩张 $310、收缩 $60、流失 $290、期初 MRR $19,280——都与事实表一致。这是一个单独的教学假设,不影响其他数字。

项预算实际差额偏差率
期初 MRR$19,280$19,280$00.0%
新签+$780(10 家 × $78)+$624(8 家 × $78)−$156−20.0%
扩张+$310+$310$00.0%
收缩−$60−$60$00.0%
流失−$290−$290$00.0%
期末 MRR$20,020$19,864−$156−0.8%
Q1 收入(×3)$60,060$59,592−$468−0.8%

Q1 偏差率只有 0.78%,绝对额 $468,按季度看是 ok:既不到 5%,也不到 $1,000。但把同样的缺口年化,全年差额是 4 × $468 = $1,872,超过 $1,000 的 watch 线,仍低于 $2,500 的 act 线,所以判定是 watch。这个例子说明:季度 ok 不等于全年 ok,年化是第二道筛子。

把 Q1 收入偏差拆开,只有新签这一条线动了。按价格、数量、结构分解:价格仍是 $78,数量从 30 个「客户-月」降到 24 个,价格效应为 0,数量效应是 −2 家 × $78 × 3 个月 = −$468,结构效应为 0。偏差不是降价换来的,而是新签节奏慢了两家;校准应该问渠道和销售周期,而不是改价格。

滚动预测把 Q1 的学习带进后三个季度:

全年预测 = Q1 实际 + 剩余三季度预算 + 修正
         = $59,592 + $180,180 − $1,404
         = $238,368
与预算差额 = $238,368 − $240,240 = −$1,872(−0.78%)

修正 −$1,404 是「新签每月少两家」在剩余三个季度的累计影响:2 家 × $78 × 3 个月 × 3 个季度 = $1,404。加上 Q1 的 $468,正好是全年 $1,872。如果判断 Q1 只是销售周期推迟、Q2 会补回来,修正可以取 0,全年预测就是 $239,772;两种预测的差别不是算术,而是对「暂时还是结构」的判断。

Tidecast:开发延后一个月,机会成本多 $6,000

Tidecast 的项目预算按 14 个月开发、每月 $6,000 机会成本计算,开发成本 $84,000。如果实际用了 15 个月,开发机会成本变成 $90,000,差额 +$6,000,偏差率 +7.14%。

项预算实际差额偏差率判定
开发月数1415+1+7.1%
开发机会成本$84,000$90,000+$6,000+7.1%act

偏差率 7.14% 只在 watch 区间,但绝对额 $6,000 超过 $2,500 的 act 线,所以判定是 act。成本偏差的方向是不利:多花的一个月不会带来收入,只会推迟上线。项目成本从 $101,000 升到 $107,000,扣掉开发机会成本后的结余从 $37,171 降到 $31,171;按单份净得 $14.39 算,项目保本销量从约 7,018 份升到约 7,434 份。

这里要区分「现金」和「机会成本」。延后一个月的 $6,000 不一定是账上流出的现金,可能是创始人少领的工资或少接的外包;但它在项目预算里是真实成本,因为那一个月本来可以用于别的收入。用现金流口径看,延期可能只表现为「个人账户少了一笔」,用项目口径看,它是结余减少 $6,000。两个口径都要保留,不能用一个否定另一个。

开发延期还是领先指标。它发生在收入之前,等上线后看到销量不及预期再回头找原因,已经晚了。项目制预算的校准频率应该跟里程碑走,而不是跟自然季度走;里程碑延期一周,就值得问一次「范围是否膨胀」。

Nord Studio:工时少 30 小时,偏差率 9.1% 仍触发行动

Nord Studio 的咨询线按 110 小时/月编预算,Q1 预算 330 小时 × $85 = $28,050。如果实际可计费工时只有 300 小时,收入 $25,500,差额 −$2,550,偏差率 −9.09%。

项预算实际差额偏差率判定
可计费工时330300−30−9.1%
咨询收入$28,050$25,500−$2,550−9.1%act

偏差率 9.09% 没有到 10% 的 act 线,但绝对额 $2,550 超过 $2,500,仍然触发 act。这正是「或」阈值的意义:如果只看百分比,会漏掉一个已经超过行动金额的缺口;如果只看金额,又会放过一个小科目上的巨大比例偏差。

30 小时缺口对 Nord 的打击不只是 $2,550 收入。咨询线的贡献率约 83.96%,少 $2,550 收入大约少 $2,141 贡献;全年如果每季都少 30 小时,就是 $8,560 贡献,接近 SDE $34,320 的四分之一。工时是咨询线的产能上限,缺口通常来自需求不足、排期空档或合伙人被产品线占用;校准要落到「补哪一段排期」或「降哪一项固定成本」,而不是只改收入数字。

产品线不应该用同一个偏差率判断。产品收入由 90 家 × $39 驱动,月流失 3.0%,客户数的变化比单月收入更早暴露问题;把两条线合并成一个总收入偏差,会互相掩盖。双轨预算的控制点见 5.1 自上而下与自下而上的预算 。

季度校准:把一次偏差分成暂时与结构

季度校准不是把预算改成实际,而是回答「下一季的剩余预算要不要改」。同样一个缺口,至少有三类原因:

偏差来源暂时因素结构因素校准动作
新签节奏销售周期推迟、假期渠道失效、价格过高只修下一季,或改全年
可计费工时客户排期空档需求下降、合伙人被占用补排期,或降固定成本
开发延期外包排期、生病范围膨胀、技术债一次性追回,或改预算

判断顺序是:先看偏差是否越过阈值,再看它是否会在剩余季度重复,最后看重复的原因是暂时还是结构。暂时因素只修正下一季,结构因素才改全年;把一次性波动写进全年预算,会让预算失去约束力。

滚动预测是校准的落点:

全年预测 = 年初至今实际 + 剩余期间原预算 + 修正

修正可以按季度滚动更新,但不必每月重算。月度关账负责现金和账实一致,季度校准负责经营判断;两者频率不同,混在一起会让团队追着噪声跑。月度关账的流程见 月度关账 。

校准还要留出情景。LedgerLite 的 Q1 缺口如果 Q2 补回,全年接近预算;如果补不回,全年少 $1,872;如果新签继续降到 6 家,全年缺口会更大。把「补回」「持平」「继续恶化」三种情景写出来,比给一个点预测更有用;情景模拟的方法见 8.2 情景模拟 。现金层面的短期排程见 8.1 13 周现金流表 。

把偏差写成代码

下面的 variance.ts 把偏差测量、阈值判定和滚动预测固化成三个函数:measureVariance 计算绝对额、偏差率和方向,classifyVariance 用「比率或金额」两道阈值给出 ok / watch / act,rollingForecast 把实际、剩余预算和修正合成全年预测。

// file: src/fin/variance.ts
// tier: L1
// verify: node --test test/fin/variance.test.ts

import { type Cents } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type VarianceDirection = "revenue" | "cost";

export type Severity = "ok" | "watch" | "act";

export type VarianceLimits = {
  watchBps: Bps;
  actBps: Bps;
  watchCents: Cents;
  actCents: Cents;
};

export const DEFAULT_VARIANCE_LIMITS: VarianceLimits = {
  watchBps: 500,
  actBps: 1000,
  watchCents: 100000,
  actCents: 250000,
};

export type Variance = {
  actual: Cents;
  budget: Cents;
  absolute: Cents;
  rateBps: Bps;
  favorable: boolean;
};

/**
 * Measures actual against budget. For revenue a positive gap is favorable;
 * for cost a positive gap is an overrun and therefore unfavorable. The rate is
 * signed against the absolute budget, so -780 bps means 7.8% below budget.
 */
export function measureVariance(
  actual: Cents,
  budget: Cents,
  direction: VarianceDirection,
): Result<Variance> {
  if (!Number.isInteger(actual)) {
    return fail("actual must be an integer number of cents");
  }
  if (!Number.isInteger(budget) || budget <= 0) {
    return fail("budget must be a positive integer number of cents");
  }
  const absolute = actual - budget;
  const favorable = direction === "revenue" ? absolute >= 0 : absolute <= 0;
  return ok({
    actual,
    budget,
    absolute,
    rateBps: Math.round((absolute * 10000) / Math.abs(budget)),
    favorable,
  });
}

function checkLimits(limits: VarianceLimits): string | null {
  const values = [
    limits.watchBps,
    limits.actBps,
    limits.watchCents,
    limits.actCents,
  ];
  if (values.some((value) => !Number.isInteger(value) || value < 0)) {
    return "variance limits must be non-negative integers";
  }
  if (limits.watchBps > limits.actBps) {
    return "watch rate must not exceed act rate";
  }
  if (limits.watchCents > limits.actCents) {
    return "watch amount must not exceed act amount";
  }
  return null;
}

/**
 * Two independent triggers: a relative threshold and an absolute threshold.
 * Either one can escalate. annualizeQuarters scales the absolute gap before
 * the amount test, which is how a small quarterly miss becomes visible when it
 * is expected to repeat.
 */
export function classifyVariance(
  variance: Variance,
  limits: VarianceLimits = DEFAULT_VARIANCE_LIMITS,
  annualizeQuarters = 1,
): Result<Severity> {
  const error = checkLimits(limits);
  if (error) {
    return fail(error);
  }
  if (!Number.isInteger(annualizeQuarters) || annualizeQuarters < 1) {
    return fail("annualizeQuarters must be a positive integer");
  }
  const rate = Math.abs(variance.rateBps);
  const amount = Math.abs(variance.absolute) * annualizeQuarters;
  if (rate >= limits.actBps || amount >= limits.actCents) {
    return ok("act");
  }
  if (rate >= limits.watchBps || amount >= limits.watchCents) {
    return ok("watch");
  }
  return ok("ok");
}

export type RollingForecast = {
  actualToDate: Cents;
  remainingBudget: Cents;
  correction: Cents;
  fullYear: Cents;
  changeVsBudget: Cents;
  changeBps: Bps;
};

/**
 * Rolling forecast after a quarter closes:
 *   full year = actual to date + remaining budget + correction
 * The correction is where the quarter's learning enters; it can be negative.
 */
export function rollingForecast(
  actualToDate: Cents,
  remainingBudget: Cents,
  correction: Cents,
  annualBudget: Cents,
): Result<RollingForecast> {
  if (!Number.isInteger(actualToDate) || actualToDate < 0) {
    return fail("actual to date must be a non-negative integer");
  }
  if (!Number.isInteger(remainingBudget) || remainingBudget < 0) {
    return fail("remaining budget must be a non-negative integer");
  }
  if (!Number.isInteger(correction)) {
    return fail("correction must be an integer number of cents");
  }
  if (!Number.isInteger(annualBudget) || annualBudget <= 0) {
    return fail("annual budget must be a positive integer");
  }
  const fullYear = actualToDate + remainingBudget + correction;
  const changeVsBudget = fullYear - annualBudget;
  return ok({
    actualToDate,
    remainingBudget,
    correction,
    fullYear,
    changeVsBudget,
    changeBps: divide(changeVsBudget, annualBudget),
  });
}

代码有四个细节。第一,偏差率带符号:收入 −780 bps 表示低于预算 7.8%,成本 +714 bps 表示超支 7.14%,符号和方向分开表达。第二,classifyVariance 的比率与金额是「或」关系,任一越线就升级;如果改成「且」,Nord 的 $2,550 缺口会因为 9.09% 不到 10% 而被漏掉。第三,annualizeQuarters 只放大绝对额,不放大比率;年化的是「如果重复会是多少钱」,不是「比率会变成多少」。第四,rollingForecast 的修正允许为负,全年预测可以低于预算;它不替你做「暂时还是结构」的判断,只负责把判断结果算成数字。

把三个案例代入:LedgerLite 的 Q1 偏差 −$468、−78 bps,年化后判 watch;Tidecast 的开发超支 +$6,000、+714 bps,判 act;Nord 的工时缺口 −$2,550、−909 bps,判 act。三者都不是靠单看百分比得出的结论。

小结

  • 偏差先看方向:收入高于预算有利,成本高于预算不利;同一个正数在两张表里含义相反。
  • 判定要同时看偏差率和绝对额,两条阈值取「或」;只看一个会漏掉小科目的大比例或大科目的小比例。
  • LedgerLite 的 Q1 缺口只有 −$468、−0.78%,按季是 ok;年化 $1,872 后变成 watch。
  • Tidecast 开发延后一个月,机会成本多 $6,000,偏差率 7.14% 但绝对额触发 act;项目结余从 $37,171 降到 $31,171。
  • Nord 的工时缺口 −$2,550、−9.09%,百分比不到 10% 仍因绝对额触发 act。
  • 季度校准先分暂时与结构,再决定只修下一季还是改全年;滚动预测 = 实际 + 剩余预算 + 修正。

阅读导航:上一节:5.2 费用结构与固定/变动成本 · 下一节:6.1 涨价对收入与流失的净效应 。

常见错误与边界

  1. 只看偏差正负。 收入 +$2,550 是好事,成本 +$2,550 是超支;先约定方向再谈幅度。
  2. 只看百分比。 Nord 的 −9.09% 看起来不到 10%,但 −$2,550 已经超过行动金额;百分比和绝对额要一起看。
  3. 只看绝对额。 小科目上的 $300 可能是 30% 的偏差,说明分类或录入有问题;不能因为金额小就忽略。
  4. 把季度 ok 当全年 ok。 LedgerLite 的 Q1 −$468 年化后是 $1,872,越过 watch 线;季度阈值要配年化。
  5. 年化比率而不是金额。 年化放大的是「重复发生的钱」,不是把 0.78% 变成 3.12%;比率仍然按单期算。
  6. 把机会成本当现金。 Tidecast 延期一个月的 $6,000 可能没有现金流出,但项目结余确实少了 $6,000。
  7. 开发延期等上线再处理。 里程碑延期是领先指标,应该在项目预算里按里程碑校准,而不是等年度偏差。
  8. 把两条线合成一个偏差。 Nord 的咨询工时和产品客户数驱动不同,合并后会互相掩盖。
  9. 每次波动都改全年预算。 把一次性因素写进全年预算,会让预算失去约束力;先分暂时与结构。
  10. 修正没有记录。 滚动预测的修正值要写明依据和日期,否则下一季无法判断当时为什么这么改。

偏差校准的下一步是定价。预算告诉你「少签两家」值多少钱,定价告诉你「每个客户应该收多少」;下一章从涨价对收入与流失的净效应开始。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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