11.3 立项决策与止损线

把「累计投入 vs 预期回收」做成可调参数的止损线模型:S 层脚本在五个检查点算回本千分比并判定继续/观察/止损,B 层脚本做目标回收 sweep 与阈值扰动,找出最影响结论的假设,讲清立项决策到底什么时候该停手,并附一张可复用的止损决策自检表。

站内那组做小游戏服务平台的内容(小游戏服务平台专题 ),把「要不要继续投入」写成了融资叙事——什么时候该讲增长故事、什么时候该给投资人交代。本节处理的完全是另一件事:单款小游戏在做还是不做、继续还是停手这个判断上,怎么用可调的模型代替拍脑袋。前者对资本负责,后者对项目本身负责。

前面两节把立项决策的两半补齐了:11.1 定「做什么、做多少、凭什么停」,11.2 算「首版花多少钱、多少天」。这一节把最后一半——「凭什么停」——做成一整套可调、可跑、可做敏感性的模型。立项决策里最贵的错误,不是选错玩法,而是在一个已经该停的项目上继续投入。止损线的作用,就是在坏消息刚出现时,给出一个不依赖情绪的判断。

立项决策的核心是「什么时候停」

多数团队把立项理解成「决定做不做」,然后默认一旦立项就一路做到上线。这是错的。立项决策不是一次性的,而是一串检查点:每过一个里程碑,都应该重新问一次「按现在看到的信息,还值得继续吗」。一页纸里的「止损条件」就是这些检查点的判据。

难点在于,「还值得继续吗」太模糊,没法直接回答。要把它变成可计算的,得先翻译成一个比值:到目前为止投进去了多少,按当前可观察的信号,能收回来多少。这个比值持续走低、跌破某条线,就该停。这就是止损线模型的全部思想——它不预测未来,只对照当前信号与已投入成本。

止损线模型:累计投入 vs 预期回收

把思想落成参数。模型有五个检查点(gate),每个记两样东西:到该点为止的累计投入(investedCents),以及根据当前信号外推的目标回收达成率(signalPct)。再配一个全局的目标总回收(targetRecoverCents)和两条阈值线。

下面这份配置是 D 层(静态层)产物。同样注意:所有数字都是示例假设,用来演示判据算法,不代表任何真实项目或市场行情。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "示例假设:投入、目标回收、信号强度均为占位数据,用于演示「累计投入 vs 预期回收」的止损判据算法,不代表任何真实项目或市场行情。全部为整数;比值一律用千分比(整数地板除)。每个 gate 是立项后的一道检查点:investedCents 为到该点为止的累计投入,signalPct 为根据当前可观察信号外推的「目标回收达成率」。",
  "targetRecoverCents": 50000000,
  "continueThresholdPermille": 1500,
  "stopThresholdPermille": 1000,
  "sweep": { "startCents": 30000000, "stopCents": 70000000, "stepCents": 5000000 },
  "benchPerturbPct": 20,
  "gates": [
    { "id": "g1", "name": "原型验证后", "investedCents": 6800000,  "signalPct": 92 },
    { "id": "g2", "name": "系统完成",   "investedCents": 17600000, "signalPct": 78 },
    { "id": "g3", "name": "内容完成",   "investedCents": 23000000, "signalPct": 70 },
    { "id": "g4", "name": "变现接入",   "investedCents": 25880000, "signalPct": 64 },
    { "id": "g5", "name": "上线前",     "investedCents": 34760000, "signalPct": 60 }
  ]
}

五个 gate 的累计投入直接取自上一节的里程碑累计成本:原型验证后 6.8 万、系统完成 17.6 万、内容完成 23 万、变现接入 25.88 万、上线前 34.76 万。signalPct 是信号达成率——把当前能观察到的数据(原型测试意愿、留存预估、变现预估)折成一个「离目标回收还差多远」的百分比。它随 gate 递减:越往后能观察的信号越多、越接近真实,虚高的部分被挤掉。

用 S 层脚本算止损判据

下面这段是 S 层仿真脚本,对每个 gate 算预期回收、回本千分比与判定,并画出带阈值线的条形图。

#!/usr/bin/env python3
# file: sim/ch11_stoploss.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch11_stoploss.py --config config/ --out out/
"""止损线模型(S 层):把「累计投入 vs 预期回收」做成可调参数,算出止损判据。

对每个检查点 gate:
  expectedRecoverCents = targetRecoverCents × signalPct // 100   (整数地板除)
  paybackPermille      = expectedRecoverCents × 1000 // investedCents
  verdict:>= continue 阈值 → 继续;>= stop 阈值 → 观察;否则 → 止损

产出:out/ch11_stoploss.json + out/ch11_stoploss_gates.csv + out/ch11_stoploss.svg
全整数、无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

COLOR = {"继续": "#059669", "观察": "#d97706", "止损": "#dc2626"}


def verdict_of(pb: int, cont: int, stop: int) -> str:
    return "继续" if pb >= cont else ("观察" if pb >= stop else "止损")


def draw_svg(path: str, rows: list[dict], cont: int, stop: int, span: int) -> int:
    W, PAD, ROW = 780, 150, 46
    H = 90 + ROW * len(rows)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》各检查点回本千分比(回收 ÷ 投入 × 1000)")
    pw = W - PAD - 40
    for lvl, label, color in ((cont, f"继续线 {cont}‰", "#059669"), (stop, f"止损线 {stop}‰", "#dc2626")):
        x = PAD + (pw * lvl) // span
        ET.SubElement(svg, "line", {"x1": str(x), "y1": "46", "x2": str(x), "y2": str(H - 20),
                                    "stroke": color, "stroke-width": "1", "stroke-dasharray": "4 3"})
        ET.SubElement(svg, "text", {"x": str(x + 4), "y": "42", "font-size": "11", "fill": color}).text = label
    for i, r in enumerate(rows):
        y = 60 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = r["name"]
        w = (pw * r["paybackPermille"]) // span
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(max(w, 2)),
                                    "height": "22", "rx": "4", "fill": COLOR[r["verdict"]]})
        ET.SubElement(svg, "text", {"x": str(PAD + max(w, 2) + 8), "y": str(y + 22), "font-size": "11",
                                    "fill": "#6b7280"}).text = f'{r["paybackPermille"]}‰ · {r["verdict"]}'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = json.load(open(os.path.join(a.config, "ch11_stoploss.json"), encoding="utf-8"))
    target, cont, stop = cfg["targetRecoverCents"], cfg["continueThresholdPermille"], cfg["stopThresholdPermille"]
    rows, first = [], None
    for g in cfg["gates"]:
        rec = target * g["signalPct"] // 100
        pb = rec * 1000 // g["investedCents"] if g["investedCents"] else 0
        v = verdict_of(pb, cont, stop)
        if v == "止损" and first is None:
            first = g["id"]
        rows.append({"id": g["id"], "name": g["name"], "investedCents": g["investedCents"],
                     "signalPct": g["signalPct"], "expectedRecoverCents": rec,
                     "paybackPermille": pb, "verdict": v})
    counts = {k: sum(1 for r in rows if r["verdict"] == k) for k in ("继续", "观察", "止损")}
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
           "targetRecoverCents": target, "continueThresholdPermille": cont, "stopThresholdPermille": stop,
           "gateCount": len(rows), "verdictCounts": counts, "firstStopGate": first, "gates": rows}
    os.makedirs(a.out, exist_ok=True)
    span = max((r["paybackPermille"] for r in rows), default=1)
    rep["svg"] = os.path.join(a.out, "ch11_stoploss.svg")
    rep["svgBytes"] = draw_svg(rep["svg"], rows, cont, stop, span)
    json.dump(rep, open(os.path.join(a.out, "ch11_stoploss.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "ch11_stoploss_gates.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "investedCents", "signalPct", "expectedRecoverCents",
                    "paybackPermille", "verdict"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["investedCents"], r["signalPct"],
                        r["expectedRecoverCents"], r["paybackPermille"], r["verdict"]])
    print(json.dumps({"gateCount": len(rows), "verdictCounts": counts, "firstStopGate": first,
                      "paybackByGate": {r["id"]: r["paybackPermille"] for r in rows}}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"gateCount": 5, "verdictCounts": {"继续": 3, "观察": 1, "止损": 1}, "firstStopGate": "g5", "paybackByGate": {"g1": 6764, "g2": 2215, "g3": 1521, "g4": 1236, "g5": 863}}

同时落盘 out/ch11_stoploss_gates.csv(每 gate 一行)与 out/ch11_stoploss.svg(带阈值线的条形图)。CSV 是这样:

id,name,investedCents,signalPct,expectedRecoverCents,paybackPermille,verdict
g1,原型验证后,6800000,92,46000000,6764,继续
g2,系统完成,17600000,78,39000000,2215,继续
g3,内容完成,23000000,70,35000000,1521,继续
g4,变现接入,25880000,64,32000000,1236,观察
g5,上线前,34760000,60,30000000,863,止损

判定读出来的三个结论

回本千分比一路从 6764‰ 掉到 863‰,注意三件事。

第一,比值下降不等于项目变差。 g1 的 6764‰ 看着极健康,但它是假象:原型刚验证完只花了 6.8 万,而信号达成率 92% 是拿「小样本的原型测试意愿」外推的,误差极大。随着投入变多、信号变真,这个比值必然下降。下降本身是正常的,关键看它掉到哪条线。这正是阈值要有两条的原因——g4 的 1236‰ 落进「观察」区,不是让你立刻停,而是让你提高警惕、加紧验证。

第二,止损第一次触发在 g5(上线前),此时已投入 34.76 万。 这是最该被看见的数字:等到「上线前」才止损,代价是几乎全部预算。所以真正有用的止损,从来不是等到 g5——而是在更早的 gate 就设好触发条件。g4 进观察区,就是提前给的一次警告:若下一步信号继续走低,别等到上线前才停。

第三,千分比是整数地板除的结果。 paybackPermille = 回收 × 1000 // 投入,全是整数运算。以 g5 为例:回收 30000000 分 × 1000 // 投入 34760000 分 = 863‰。// 向下取整,宁可少算一点也不引入浮点——这样 Python 侧与 TS 内核侧才能逐位一致。

参数敏感性:哪些假设最影响结论

「g5 止损」是一个结论,但结论依赖一堆假设:目标回收 50 万、信号达成率、两条阈值。如果某个假设稍变结论就翻盘,这个结论就不可靠。所以止损线不能只跑一次,必须做参数敏感性。下面这段是 B 层基准脚本,做三组扰动:目标回收 sweep、单点信号 ±20%、阈值 ±20%,看每组让几个 gate 判定翻转。

#!/usr/bin/env python3
# file: sim/ch11_stoploss_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch11_stoploss_bench.py --config config/ --out out/
"""止损线模型参数敏感性基准(B 层,软告警不硬失败)。

三组扰动:目标回收 sweep、单点信号 ±pct、两条阈值 ±pct。
敏感性度量 = 扰动下判定翻转的 gate 数,最大者为「最敏感假设」。退出码恒为 0。
"""
from __future__ import annotations

import argparse
import json
import os


def compute(gates: list[dict], target: int, cont: int, stop: int) -> list[dict]:
    out = []
    for g in gates:
        pb = target * g["signalPct"] // 100 * 1000 // g["investedCents"] if g["investedCents"] else 0
        out.append({"id": g["id"], "pb": pb,
                    "verdict": "继续" if pb >= cont else ("观察" if pb >= stop else "止损")})
    return out


def first_stop(res: list[dict]):
    return next((r["id"] for r in res if r["verdict"] == "止损"), None)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    os.makedirs(a.out, exist_ok=True)
    cfg = json.load(open(os.path.join(a.config, "ch11_stoploss.json"), encoding="utf-8"))
    gates, target = cfg["gates"], cfg["targetRecoverCents"]
    cont, stop, pct = cfg["continueThresholdPermille"], cfg["stopThresholdPermille"], cfg["benchPerturbPct"]
    base = compute(gates, target, cont, stop)

    sw = cfg["sweep"]
    sweep = []
    for t in range(sw["startCents"], sw["stopCents"] + sw["stepCents"], sw["stepCents"]):
        res = compute(gates, t, cont, stop)
        sweep.append({"targetRecoverCents": t, "firstStopGate": first_stop(res),
                      "stopCount": sum(1 for r in res if r["verdict"] == "止损"),
                      "paybackByGate": {r["id"]: r["pb"] for r in res}})

    sig = []
    for i, g in enumerate(gates):
        for p in (100 + pct, 100 - pct):
            g2 = [dict(x) for x in gates]
            g2[i]["signalPct"] = g["signalPct"] * p // 100
            res = compute(g2, target, cont, stop)
            sig.append({"gate": g["id"], "perturbPct": p, "newSignalPct": g2[i]["signalPct"],
                        "verdict": res[i]["verdict"], "changed": res[i]["verdict"] != base[i]["verdict"]})

    thr = []
    for name, val in (("continue", cont), ("stop", stop)):
        for p in (100 + pct, 100 - pct):
            nv = val * p // 100
            c2, s2 = (nv, stop) if name == "continue" else (cont, nv)
            res = compute(gates, target, c2, s2)
            thr.append({"threshold": name, "perturbPct": p, "newValue": nv,
                        "flipCount": sum(1 for x, y in zip(base, res) if x["verdict"] != y["verdict"])})

    metric = {"targetSweepFlipRange": sweep[-1]["stopCount"] - sweep[0]["stopCount"],
              "signalPerturbMaxFlips": 1 if any(r["changed"] for r in sig) else 0,
              "thresholdPerturbMaxFlips": max((r["flipCount"] for r in thr), default=0)}
    most = max(metric, key=lambda k: abs(metric[k]))
    warnings = []
    if metric["signalPerturbMaxFlips"] or metric["thresholdPerturbMaxFlips"]:
        warnings.append(f'结论对参数敏感:单点信号扰动最多翻转 {metric["signalPerturbMaxFlips"]} 个、'
                        f'阈值扰动最多翻转 {metric["thresholdPerturbMaxFlips"]} 个检查点')
    if sweep[0]["stopCount"] != sweep[-1]["stopCount"]:
        warnings.append("目标回收假设从低到高,止损检查点数量发生变化——结论依赖回收假设")
    rep = {"version": 1, "source": "sim/ch11_stoploss_bench.py",
           "baseFirstStopGate": first_stop(base), "sweep": sweep,
           "signalPerturbation": sig, "thresholdPerturbation": thr,
           "sensitivityMetric": metric, "mostSensitiveAssumption": most, "warnings": warnings,
           "verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条"}
    json.dump(rep, open(os.path.join(a.out, "ch11_stoploss_bench.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    print(json.dumps({"baseFirstStopGate": rep["baseFirstStopGate"],
                      "sweepFirstStop": [(r["targetRecoverCents"], r["firstStopGate"]) for r in sweep],
                      "sensitivityMetric": metric, "mostSensitiveAssumption": most,
                      "verdict": rep["verdict"], "warnings": warnings}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

在本机跑它,输出是这样的(软告警,退出码恒为 0):

{
  "baseFirstStopGate": "g5",
  "sweepFirstStop": [
    [30000000, "g3"], [35000000, "g4"], [40000000, "g4"],
    [45000000, "g5"], [50000000, "g5"], [55000000, "g5"],
    [60000000, null], [65000000, null], [70000000, null]
  ],
  "sensitivityMetric": {"targetSweepFlipRange": -3, "signalPerturbMaxFlips": 1, "thresholdPerturbMaxFlips": 1},
  "mostSensitiveAssumption": "targetSweepFlipRange",
  "verdict": "WARN(soft) 2 条",
  "warnings": [
    "结论对参数敏感:单点信号扰动最多翻转 1 个、阈值扰动最多翻转 1 个检查点",
    "目标回收假设从低到高,止损检查点数量发生变化——结论依赖回收假设"
  ]
}

敏感性读出来的结论

sweep 那一列最该看。目标回收从 30 万扫到 70 万,首个止损点从 g3 一路推到「无」:目标回收 30 万时,早在「内容完成」(g3)就该止损;60 万以上时五个 gate 全部「继续」,模型再也不触发止损。这意味着**「什么时候停」几乎完全由「目标回收是多少」这一个假设决定**——mostSensitiveAssumption 给出的正是它。

这个发现比「g5 止损」那个具体结论更重要。它告诉你:与其纠结阈值线设 1000‰ 还是 1200‰,不如先把「目标回收 50 万」这个假设证得更实。阈值扰动(continue/stop 各 ±20%)最多只翻转 1 个 gate,单点信号扰动也最多翻转 1 个——都不如目标回收敏感。这就是敏感性分析的价值:把「该盯哪个参数」从直觉变成排序。

不过要提醒:targetSweepFlipRange 是相对量(止损点数量从 3 变到 0,差 -3),绝对值不可移植到别的项目。换一款游戏、换一组投入数字,这个数会变。真正可移植的是方法——先扫参数、再看哪个假设最影响结论、然后集中精力把那个假设证得更实。

止损线与立项决策

把三节串起来:一页纸定「做什么、凭什么停」(11.1),预算排期算「花多少、多少天」(11.2),止损线模型给出「什么信号、在哪停」(11.3)。三者共用同一份累计投入——11.2 算出的里程碑累计成本,直接成了 11.3 的 investedCents。这就是「一份 config、三种消费」在立项场景的体现:投入数字只在预算表里维护一次,止损模型直接读它。

还要说清边界。本节做的是止损判据——给定投入与信号,算「停不停」。它不做收入预测建模:目标回收 50 万是假设输入,怎么估出这个数、DAU 与 ARPDAU 怎么折算成回收,是第 13 章(商业化模型与收入预测)的活;本节也不碰 LTV 回本周期(卷三第 3 章)。本节的定位是决策框架:把「该不该停」变成可调参数、可做敏感性、可复现的模型,而不是替代收入建模。

止损决策的自检表

检查项合格标准常见缺陷
有检查点每个里程碑后重新评估立项一次,一路做到上线
有两条线继续线 + 止损线,中间是观察区只有一条线,非黑即白
投入来自预算表用 11.2 的累计成本另估一个数,与预算对不上
信号可观察每个 gate 有对应真实数据用拍脑袋的「感觉」
做过敏感性扫关键参数,排出敏感度只跑一次就下结论
定位最敏感假设集中验证最影响结论的假设纠结不重要的阈值

这张表里最容易被跳过的是「做过敏感性」。多数团队跑一次模型、看到「继续」就放心往下做;但一个没做过敏感性的结论,可能是「稍微改个假设就翻盘」的脆弱结论。而「定位最敏感假设」是敏感性的产出——它告诉你下一步该验证什么,而不是把时间花在调阈值这种不重要的事上。

到这里,第一卷从市场全景到立项止损的外部判断与决策链条就走完了。下一章进入执行的第一站——把一页纸里那句「首版做什么」落成一个可玩原型:先划清原型的范围与验证目标,再用可玩性指标去测它到底好不好玩。

阅读导航:上一节:开发预算与排期估算 · 下一节:原型范围与验证目标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制