以下情境为教学假设,人物与数字不代表经核实的真实案例。
13 周表回答「按现在的节奏,未来一个季度哪一周最紧」。情景模拟回答下一个问题:如果某个假设不成立,这张表会变成什么样。两者的区别不是精确度,而是目的:周表求准,情景求排序。你不需要预测平台一定封禁,只需要知道封禁发生时会先缺多少钱、多久之后缺、以及有没有动作窗口。
情景模拟最常见的失败不是算错,而是算得太晚。等到大客户提出解约才开始改表,现金已经进入下坡;等到退款率翻倍才去查原因,退款已经从结算里扣走。可用的做法是提前把冲击写成参数,让参数一改,表就重算。本节把四类冲击拆成可复算的输入,再用三个案例验证它们各自的承压上限。
四个参数:把冲击写成可复算的输入
任何冲击最终只改变两件事:某一周的流入变少,或某一周的流出变多。把这两件事再拆,就得到四个参数。参数化的好处是每个数字都能追溯到案例事实,而不是拍脑袋给一个「下滑 30%」。
| 参数 | 含义 | 作用方式 | 本案取值示例 |
|---|---|---|---|
| 永久折减 | 从第 1 周起就不再回来的收入占比 | 每周流入乘以(1 − 基点/10000) | LedgerLite 最大客户 9% 加新签归零 3.9%,合计 12.9% |
| 冲击折减 | 从第 N 周起额外消失的收入占比 | 第 N 周之后叠乘 | Tidecast 失去 Steam 的 80% 收入 |
| 每周新增支出 | 从第 N 周起新增的固定付款 | 第 N 周之后加到流出 | Nord 招聘第三人 55,000/年,每周 1,058 |
| 一次性收支 | 只在某一周发生的一笔 | 直接加减 | 税款、追补退款、未续约的年付 |
两个公式贯穿全节。第一,损失 = 基线金额 × 基点 ÷ 10,000;基点把「9%」写成 900,避免小数在表格里漂移。第二,退款增量 = 毛销售 ×(压力退款率 − 基线退款率)。Tidecast 的基线退款率是 4%,压力值是 12%,增量就是毛销售的 8%。
情景不是把最坏情况一次全上。一次只改一个参数,才能看出哪一类冲击真正致命;把四个参数同时拉满,得到的数字很大,但它不告诉你先处理哪一个。下面的顺序是:先看单点冲击,再看组合,最后按「到达时间 × 缺口大小」排序。
LedgerLite:大客户流失与获客断供
LedgerLite 的前五大客户占收入 31%,最大一家占 9%。9% 的 MRR 是 20,020 × 9% = 1,802 美元/月。同期新签是每月 780 美元;如果获客断供,这 780 也不再进来。两项合计 2,582 美元/月,占 MRR 的 12.9%,即 1,290 基点。
把 1,290 基点作为永久折减代入 13 周表:常规周流入从 3,234 降到 2,817,周净额从 −36 变成 −453。年付续约和税款仍按原节奏发生。
| 周 | 期初 | 流入 | 流出 | 净额 | 期末 |
|---|---|---|---|---|---|
| 1 | $95,000 | 2,817 | 3,270 | −$453 | $94,547 |
| 2 | 94,547 | 2,817 | 3,270 | −$453 | $94,094 |
| 3 | 94,094 | 2,817 | 3,270 | −$453 | $93,641 |
| 4 | 93,641 | 2,817 | 3,270 | −$453 | $93,188 |
| 5 | 93,188 | 2,817 | 3,270 | −$453 | $92,735 |
| 6 | 92,735 | 18,510 | 3,270 | $15,240 | $107,975 |
| 7 | 107,975 | 2,817 | 3,270 | −$453 | $107,522 |
| 8 | 107,522 | 2,817 | 3,270 | −$453 | $107,069 |
| 9 | 107,069 | 2,817 | 15,211 | −$12,394 | $94,675 |
| 10 | 94,675 | 2,817 | 3,270 | −$453 | $94,222 |
| 11 | 94,222 | 2,817 | 3,270 | −$453 | $93,769 |
| 12 | 93,769 | 2,817 | 3,270 | −$453 | $93,316 |
| 13 | 93,316 | 2,817 | 3,270 | −$453 | $92,863 |
13 周后现金从 95,000 降到 92,863,只少 2,137;最低点 92,735 出现在第 5 周,也就是年付续约之前。也就是说,最大客户流失加获客断供,在 13 周窗口里只让 LedgerLite 少了一层薄薄的缓冲,远没有到伤筋动骨。
把冲击放大到极限:前五大客户全部流失,31% 的收入永久消失,折 3,100 基点,每周流入降到 2,231,周净额 −1,039。13 周后现金 81,985,最低点就是第 13 周的 81,985。仍然高于 8.3 节会算出的 44,941 水位。
这就是 260 家客户、最大一家 9% 的结构价值:集中度是估值问题,不是偿付问题。同样的 31%,如果发生在一家只有 8 个客户的公司,就是生死问题。情景模拟的第一条结论因此不是「LedgerLite 很安全」,而是「它的安全来自客户数,不来自客户质量」。
Tidecast:平台封禁与退款潮
Tidecast 的风险结构完全相反。Steam 占销量 80%,平台是单一渠道,没有替代。封禁不是一个概率问题,而是一个「什么时候」的问题。把封禁写成情景:从第 5 周起失去 Steam 的全部收入,官网的 20% 保留。
单份净得会从 14.39 掉到 3.34,只剩基线的 23.2%。原因是 Steam 的 13.43 美元净得归零,官网的 18.23 美元还要乘 20% 的销量占比。再叠加退款率从 4% 升到 12%:官网单份净得从 18.23 降到 16.71,加权后仍是 3.34 左右。
现金影响有 30 天延迟。第 5 周封禁,第 9 周才在结算里体现;这四周是动作窗口,也是错觉窗口——账上还在收封禁前的钱,看起来一切正常。
| 周 | 期初 | 流入 | 流出 | 净额 | 期末 |
|---|---|---|---|---|---|
| 1 | $92,090 | 2,073 | 327 | $1,746 | $93,836 |
| 2 | 93,836 | 2,073 | 327 | $1,746 | $95,582 |
| 3 | 95,582 | 2,073 | 327 | $1,746 | $97,328 |
| 4 | 97,328 | 2,073 | 327 | $1,746 | $99,074 |
| 5 | 99,074 | 2,073 | 2,559 | −$486 | $98,588 |
| 6 | 98,588 | 2,073 | 327 | $1,746 | $100,334 |
| 7 | 100,334 | 7,974 | 327 | $7,647 | $107,981 |
| 8 | 107,981 | 7,974 | 327 | $7,647 | $115,628 |
| 9 | 115,628 | 1,852 | 327 | $1,525 | $117,153 |
| 10 | 117,153 | 1,852 | 327 | $1,525 | $118,678 |
| 11 | 118,678 | 1,852 | 327 | $1,525 | $120,203 |
| 12 | 120,203 | 371 | 327 | $44 | $120,247 |
| 13 | 120,247 | 371 | 327 | $44 | $120,291 |
第 5 周的流出 2,559 里有一笔 2,232 的追补退款:前四周出货 1,396 份、标价 27,906,退款率从 4% 升到 12%,多退 8%,即 2,232。退款是即时现金流出,不等结算周期,所以它比封禁更早出现在表里。
这张表最容易被误读的地方是期末 120,291,比期初还高。原因是 12 月的销售在 1 月才到账,而封禁要到第 9 周才影响回款。真正的信号在第 12 周以后:每周流入 371、流出 327,净额 44,等于说这门生意还在,但已经不再产生现金。
把这段节奏年化:官网 20% 的销量、12% 的退款率,年净得约 32,086;减营销 12,000、服务器 3,000、工具 2,000,SDE 从 121,171 掉到约 15,086。现金没有立刻归零,但生意从「可出售的资产」变成了「刚好覆盖成本的副业」。
退款潮单独发生时没有那么致命。4% 到 12% 让年净得从 138,171 降到 126,657,少 11,514,约 8.3%;分摊到每月不到 1,000。它的危险在于和封禁同时发生:封禁砍掉收入基数,退款潮再从剩下的基数里扣一层,两个 20% 叠乘之后只剩 23%。
Nord 与组合冲击:工资、税费与到达顺序
Nord Studio 的冲击不在收入端,在支出端。它的 13 周基线是每周 +660,第 8 周有一笔 8,580 的税款,把现金从 30,360 压到 22,440。如果从第 5 周起招聘第三人,成本是 55,000/年,折每周 1,058,周净额从 +660 变成 −398。
| 周 | 期初 | 流入 | 流出 | 净额 | 期末 |
|---|---|---|---|---|---|
| 1 | $25,740 | 2,968 | 2,308 | $660 | $26,400 |
| 2 | 26,400 | 2,968 | 2,308 | $660 | $27,060 |
| 3 | 27,060 | 2,968 | 2,308 | $660 | $27,720 |
| 4 | 27,720 | 2,968 | 2,308 | $660 | $28,380 |
| 5 | 28,380 | 2,968 | 3,366 | −$398 | $27,982 |
| 6 | 27,982 | 2,968 | 3,366 | −$398 | $27,584 |
| 7 | 27,584 | 2,968 | 3,366 | −$398 | $27,186 |
| 8 | 27,186 | 2,968 | 11,946 | −$8,978 | $18,208 |
| 9 | 18,208 | 2,968 | 3,366 | −$398 | $17,810 |
| 10 | 17,810 | 2,968 | 3,366 | −$398 | $17,412 |
| 11 | 17,412 | 2,968 | 3,366 | −$398 | $17,014 |
| 12 | 17,014 | 2,968 | 3,366 | −$398 | $16,616 |
| 13 | 16,616 | 2,968 | 3,366 | −$398 | $16,218 |
13 周后现金 16,218,比基线少 9,522。招聘本身只花 9 周 × 1,058 = 9,522,正好等于差额;税款不是新增的,它只是把低点压得更低。如果招聘之后再丢一个月咨询,再少 9,350:第 8 周的税款周低点降到 8,858,期末降到 6,868,现金只剩水位的一个零头。
Nord 的招聘还有一层约束:案例事实表说,招聘第三人需要新增收入至少 75,000 才能覆盖工资与税。55,000 的工资加上税负与毛利结构,正好落在这个量级。13 周表能看到现金少了 9,522,但看不到「这 9,522 需要 75,000 的新收入才补得回来」;后者是招聘决策的门槛,不是周表的输出。
把三案例的冲击放在一张表里,排序就出来了:
| 案例 | 情景 | 13 周现金变化 | 期末现金 | 最低点 | 相对基线 |
|---|---|---|---|---|---|
| LedgerLite | 基线 | +$5,609 | $100,609 | $94,820(第 5 周) | — |
| LedgerLite | 最大客户加获客断供 | −$2,137 | $92,863 | $92,735(第 5 周) | −$7,746 |
| LedgerLite | 前五大全部流失 | −$13,015 | $81,985 | $81,985(第 13 周) | −$18,624 |
| Tidecast | 基线 | +$51,253 | $143,343 | $92,090(期初) | — |
| Tidecast | Steam 封禁加退款 12% | +$28,201 | $120,291 | $92,090(期初) | −$23,052 |
| Nord Studio | 基线 | $0 | $25,740 | $22,440(第 8 周) | — |
| Nord Studio | 招聘第三人 | −$9,522 | $16,218 | $16,218(第 13 周) | −$9,522 |
| Nord Studio | 招聘并丢一个月咨询 | −$18,872 | $6,868 | $8,858(第 8 周) | −$18,872 |
排序不是按缺口大小,而是按「缺口什么时候到达」和「有没有动作窗口」。LedgerLite 的缺口最小,但它的动作窗口最长,因为客户还在、续约还在、渠道还在。Tidecast 的 13 周缺口 23,052 看起来可控,但它的动作窗口只有一个结算周期;封禁一旦生效,能改的只有官网占比和退款率,而这两个变量都不在 13 周内翻盘。
Nord 的缺口绝对值不算大,危险等级却最高,因为它的现金本来就贴着水位线。9,522 的额外支出对 LedgerLite 是一层缓冲,对 Nord 是把水位从 22,440 压到 16,218。同样的绝对数字,在不同基数上是不同的性质。
组合冲击的原则因此是:先处理「缺口到达最早」的那一个,再处理「缺口占现金比例最大」的那一个。前者决定你有没有时间,后者决定你还剩多少。两个维度都指向同一个动作时,不要犹豫;指向不同动作时,先保时间。
把情景写成代码
下面的 scenarios.ts 把四个参数固化成一次变换:输入 13 周基线和情景规格,输出改写后的周表,再交给 buildCash13 计算期末与最低点。参数全部用基点,一次性收支用带符号的美分,退款增量单独提供一个函数。
// file: src/cash/scenarios.ts
// tier: L1
// verify: node --test test/cash/scenarios.test.ts
import { type Cents } from "../core/money.ts";
import { type Result, fail, ok } from "../core/result.ts";
import {
type Cash13,
type WeekFlow,
buildCash13,
WEEKS_PER_QUARTER,
} from "./cash13.ts";
export const BPS = 10_000;
/**
* A stress scenario is described by four independent knobs so that each one can
* be sourced from a different case fact:
* permanentLossBps a share of every week's inflow that never comes back
* shockLossBps an extra share removed from startWeek onward
* weeklyOutflowDelta a recurring payment added from startWeek onward
* oneOffInflow/outflow a single lump in a named week (renewal, tax, refund)
* All shares are basis points; 10,000 bps is a total loss.
*/
export type ScenarioSpec = {
name: string;
permanentLossBps: number;
shockLossBps: number;
startWeek: number;
weeklyOutflowDelta: Cents;
oneOffInflow: Cents;
oneOffInflowWeek: number;
oneOffOutflow: Cents;
oneOffOutflowWeek: number;
};
export type ScenarioOutcome = Cash13 & {
name: string;
lostInflow: Cents;
};
export function defaultSpec(name: string): ScenarioSpec {
return {
name,
permanentLossBps: 0,
shockLossBps: 0,
startWeek: 1,
weeklyOutflowDelta: 0,
oneOffInflow: 0,
oneOffInflowWeek: 1,
oneOffOutflow: 0,
oneOffOutflowWeek: 1,
};
}
/** Rounded share of an amount, so 900 bps of 20,020 keeps whole cents. */
export function share(amount: Cents, bps: number): Cents {
return Math.round((amount * bps) / BPS);
}
/**
* Extra refund cash when the refund rate moves from baseBps to stressBps.
* Tidecast: 400 bps to 1,200 bps is 8% of gross sales handed back.
*/
export function refundDelta(
grossSales: Cents,
baseRefundBps: number,
stressRefundBps: number,
): Result<Cents> {
if (!Number.isInteger(grossSales) || grossSales < 0) {
return fail("gross sales must be a non-negative integer number of cents");
}
if (
!Number.isInteger(baseRefundBps) ||
!Number.isInteger(stressRefundBps) ||
baseRefundBps < 0 ||
stressRefundBps < 0 ||
stressRefundBps > BPS
) {
return fail("refund rates must be integers between 0 and 10,000 bps");
}
if (stressRefundBps <= baseRefundBps) {
return fail("stress refund rate must exceed the base rate");
}
return ok(share(grossSales, stressRefundBps - baseRefundBps));
}
function validateSpec(spec: ScenarioSpec): Result<true> {
const rates = [spec.permanentLossBps, spec.shockLossBps];
for (const rate of rates) {
if (!Number.isInteger(rate) || rate < 0 || rate > BPS) {
return fail("loss rates must be integers between 0 and 10,000 bps");
}
}
if (spec.permanentLossBps + spec.shockLossBps > BPS) {
return fail("combined loss cannot exceed 10,000 bps");
}
if (
!Number.isInteger(spec.startWeek) ||
spec.startWeek < 1 ||
spec.startWeek > WEEKS_PER_QUARTER
) {
return fail(`start week must be between 1 and ${WEEKS_PER_QUARTER}`);
}
if (!Number.isInteger(spec.weeklyOutflowDelta)) {
return fail("weekly outflow delta must be an integer");
}
if (!Number.isInteger(spec.oneOffInflow) || !Number.isInteger(spec.oneOffOutflow)) {
return fail("one-off amounts must be integers");
}
return ok(true);
}
/**
* Rewrites a base week series. Losses compound: the permanent share is removed
* first, then the shock share applies to what is left. The one-off inflow is
* signed, so a renewal that failed to land is a negative number.
*/
export function applyScenario(
base: WeekFlow[],
spec: ScenarioSpec,
): Result<WeekFlow[]> {
const checked = validateSpec(spec);
if (!checked.ok) {
return checked;
}
const out: WeekFlow[] = [];
for (let i = 0; i < base.length; i += 1) {
const week = i + 1;
const keepPermanent = BPS - spec.permanentLossBps;
const keepShock = week >= spec.startWeek ? BPS - spec.shockLossBps : BPS;
let inflow = Math.round(
(base[i].inflow * keepPermanent * keepShock) / (BPS * BPS),
);
if (week === spec.oneOffInflowWeek) {
inflow += spec.oneOffInflow;
}
let outflow = base[i].outflow;
if (week >= spec.startWeek) {
outflow += spec.weeklyOutflowDelta;
}
if (week === spec.oneOffOutflowWeek) {
outflow += spec.oneOffOutflow;
}
out.push({ inflow, outflow });
}
return ok(out);
}
/** Applies a scenario and runs it through the 13-week table. */
export function runScenario(
openingCash: Cents,
base: WeekFlow[],
spec: ScenarioSpec,
): Result<ScenarioOutcome> {
const applied = applyScenario(base, spec);
if (!applied.ok) {
return applied;
}
const built = buildCash13(openingCash, applied.value);
if (!built.ok) {
return built;
}
const baseInflow = base.reduce((acc, row) => acc + row.inflow, 0);
return ok({
...built.value,
name: spec.name,
lostInflow: baseInflow - built.value.totalInflow,
});
}
代码里有两个容易被忽略的约定。第一,折减是叠乘而不是相加:先扣永久折减,再扣冲击折减,因此 12.9% 和 80% 同时存在时,保留的是 87.1% × 20%,而不是 7.1%。第二,一次性流入带符号,未续约的年付写成负数,和「退款追补」这种流出分开记,避免把两类不同性质的事混在一个字段里。
退款增量的函数只算差额,不重算整张表:毛销售 27,906 美元乘以 800 基点,得到 2,232 美元,也就是 223,248 美分。这个数量级差异是基点最容易出错的地方——share 返回的是美分,而表里展示的是美元。
情景跑完不是终点。8.3 节会把「期末现金」换成「还能撑几个月」,把「最低点」换成「离水位线还差多少」。周表看周,情景看事件,runway 看月份,三者是同一组数字的三种时间尺度。
小结
- 四类冲击可以拆成四个参数:永久折减、从第 N 周起的冲击折减、每周新增支出、单周一次性收支;全部用基点,避免小数漂移。
- LedgerLite 的最大客户 9% 加新签归零 3.9%,13 周只让现金从 95,000 降到 92,863;前五大全部流失也只到 81,985,集中度是估值问题,不是偿付问题。
- Tidecast 的 Steam 封禁有 30 天结算延迟,第 5 周封禁、第 9 周才见现金;期末 120,291 看似安全,第 12 周后周净额只剩 44,年化 SDE 从 121,171 掉到约 15,086。
- 退款率从 4% 升到 12% 单独发生只少 11,514 年净得,约 8.3%;危险在于和封禁叠乘,两个折减之后只剩 23%。
- Nord 招聘第三人每周多 1,058,13 周少 9,522;再丢一个月咨询 9,350,期末只剩 6,868,第 8 周低点 8,858,而它的现金本来只有 25,740。
- 组合冲击按「到达时间」和「占现金比例」两个维度排序:先保时间,再保余量。
阅读导航:上一节:8.1 13 周现金流表 · 下一节:8.3 runway 与最低现金水位 。
常见错误与边界
- 把情景当预测。 情景不是「预计会跌 30%」,而是「如果跌 30%,第几周会缺钱」;它的输出是排序和动作窗口,不是概率。
- 折减相加而不是叠乘。 12.9% 加 80% 不是 92.9%;封禁后再退款,保留的是 87.1% × 20%,两个百分比作用在不同的基数上。
- 忽略结算延迟。 Tidecast 第 5 周封禁,现金到第 9 周才变;按封禁当周记缺口,会误判动作窗口。
- 把退款当收入折减。 退款是即时现金流出,且退款率变化同时影响已售和将售;它和「少卖几份」的现金流时点不同。
- 只跑最坏组合。 四个参数同时拉满只能得到一个吓人的数字;单点冲击才能告诉你先处理哪一个。
- 忘记一次性项目的符号。 未续约的年付是流入的减项,税款和追补退款是流出的加项;符号反了,情景会变成利好。
- 用美元和基点混算。
share返回美分,表格展示美元;乘除 100 的位置错一次,结论就差两个数量级。 - 把 13 周缺口当全部损失。 封禁的 13 周缺口 23,052 只是开始;真正的损失是之后的年化收入,要交给 runway 和估值去算。
- 忽略招聘的收入门槛。 Nord 的第三人每周多 1,058,但需要新增 75,000 年收入才覆盖;周表只显示支出,不显示这个门槛。
- 情景不更新。 参数要随合同、渠道和退款政策变化重跑;三个月前的情景,可能已经不描述今天的生意。
本节的边界是「单点冲击加线性折减」:它假设冲击在一周内发生、之后保持恒定,不模拟渐变、恢复和二次冲击。真实世界里的封禁可能申诉成功,退款潮可能两周后回落,大客户可能只走一半。这些非线性需要用多个情景包络,而不是一个精确值。下一节把情景的期末现金接到 runway 与最低水位,回答「还能撑多久」和「什么时候必须动作」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。