14.3 止损与再投入决策

止损与再投入决策把 14.1 的单位账与 14.2 的月度账合成一张组合决策表:每条渠道按 D30 ROAS 判成止损、维持或加投,加投渠道求「边际 ROAS 落到回本线」的最优放量倍数,止损渠道算省下的亏损,最后汇总净改善金额;配可跑的 Python 仿真与 TypeScript 内核跨语言对拍。

站内《小游戏开发实战》卷三目录 把收入、LTV 与增长预算分配算得很细,本章 14.1 算了单用户账、14.2 算了月度账。但账算出来只是原料,操盘手每个周期真正要做的是一个组合决策:手里几条渠道,哪条该停、哪条该留、哪条该加钱,加多少。本节不重做卷三 3 章的收入模型,也不重做 2.3 的单条计划内关停规则(那条讲「一条计划的累计 ROAS 连续低于止损线就关」),而是站在组合层:把 14.1 的单位账与 14.2 的现金约束合起来,判每条渠道的动作,并给出加投的最优倍数。分工是:2.3 管一条计划什么时候关,本节管一整个投放组合的钱往哪放。这也正是本章的收口——前面所有的账,最后都要落到「停、留、加」三个字上。

一个组合,三种动作

对每条渠道,先算一个数:roasPermille = ltvFen30 × 1000 // cacFen,也就是单用户 D30 收入与获取成本的比(1000‰ = 回本线)。然后按两条线分三种动作:

动作条件含义
STOP(止损)ROAS < 止损线这条渠道在失血,停掉它省下的就是少亏的
HOLD(维持)止损线 ≤ ROAS < 加投线能赚但不够多,维持现状观察
SCALE(加投)ROAS ≥ 加投线值得加钱,但要算清加到哪一点

难点在 SCALE。加投不是线性放大:放量会推高边际 CAC(好的人群先被买完,后面越来越贵)。所以「加多少」有一个最优点——加到边际 ROAS 落到回本线为止。再往后每多投一块钱,收回来的还不够花出去的。

决策公式

把决策写成整数公式(金额用「分」,比例用千分比):

roasPermille(c)  = ltvFen30(c) × 1000 // cacFen(c)
判定:roas < stopFloor → STOP;roas ≥ scaleFloor → SCALE;否则 HOLD
# SCALE 渠道求「边际 ROAS 落到 1000」的放量倍数:
scaleStar(c)     = min(maxScale, 1000 + (roas − 1000) × 1000 // scaleDriftPermille)
cacEnd(c)        = cacFen × (1000 + scaleDrift × (scaleStar − 1000) // 1000) // 1000
avgCac(c)        = (cacFen + cacEnd) // 2                       # 线性成本曲线增量段均值
extraSpend(c)    = baseSpendFen × (scaleStar − 1000) // 1000
extraInstalls(c) = extraSpend // avgCac
extraProfit(c)   = extraInstalls × ltvFen30 − extraSpend        # 加投的增量利润
savedLoss(c)     = baseSpendFen × (1000 − roas) // 1000         # STOP 渠道省下的亏损

scaleDriftPermille 是放量的边际成本斜率——每多投一倍基数,CAC 上升的千分比。它越大,最优点越早到,说明这条渠道「不经放」。avgCac 取增量段两端均值,是因为在「CAC 随放量线性上升」的假设下,增量段的平均成本恰好是两端平均(梯形积分),这样 extraProfit 才不是用端点高估成本、低估利润。汇总的 netGain = ΣextraProfit + ΣsavedLoss 就是这一轮「停、加」合计带来的金额改善。

S 层仿真:把组合决策算出来

下面这段是 S 层仿真脚本 sim/o14_decision.py。它读 config/o14_decision.json 的四条渠道,逐条判动作、算加投倍数与增量利润、算止损省下的亏损,汇总净改善,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o14_decision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o14_decision.py --config config/ --out out/
"""止损与再投入决策:把每条渠道判成「止损 / 维持 / 加投」,再算出加投的最优倍数与增量利润。

读 config/o14_decision.json 的四条渠道。每条:

    roasPermille(c) = ltvFen30(c) * 1000 // cacFen(c)                 # D30 ROAS(1000 = 回本线)
    判定:roas < stopFloor  → STOP(止损)
          roas >= scaleFloor → SCALE(加投)
          其余              → HOLD(维持)
    # SCALE 渠道求「边际 ROAS 落到 1000 的那一点」作为最优放量倍数:
    scaleStar(c)    = min(maxScale, 1000 + (roas - 1000) * 1000 // scaleDriftPermille)
    cacEnd(c)       = cacFen * (1000 + scaleDrift * (scaleStar - 1000) // 1000) // 1000
    avgCac(c)       = (cacFen + cacEnd) // 2                          # 线性成本曲线增量段均值
    extraSpend(c)   = baseSpendFen * (scaleStar - 1000) // 1000
    extraInstalls(c)= extraSpend // avgCac
    extraProfit(c)  = extraInstalls * ltvFen30 - extraSpend           # 加投的增量利润(分)
    # STOP 渠道:停掉即不再亏:
    savedLoss(c)    = baseSpendFen * (1000 - roas) // 1000

汇总:totalExtraProfit(加投增量利润)、totalSavedLoss(止损省下的亏损)、netGain = 两者之和。
全程整数、地板除,无浮点。产出 out/o14_decision.json + .csv + .svg。
真实出价与真实边际成本曲线需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations

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


def evaluate(c: dict, stop_floor: int, scale_floor: int, max_scale: int) -> dict:
    cac, ltv = c["cacFen"], c["ltvFen30"]
    roas = ltv * 1000 // cac
    if roas < stop_floor:
        verdict = "STOP"
    elif roas >= scale_floor:
        verdict = "SCALE"
    else:
        verdict = "HOLD"

    scale_star = 1000
    extra_profit = 0
    extra_spend = 0
    if verdict == "SCALE":
        drift = c["scaleDriftPermille"]
        scale_star = min(max_scale, 1000 + (roas - 1000) * 1000 // drift)
        cac_end = cac * (1000 + drift * (scale_star - 1000) // 1000) // 1000
        avg_cac = (cac + cac_end) // 2
        extra_spend = c["baseSpendFen"] * (scale_star - 1000) // 1000
        extra_installs = extra_spend // avg_cac
        extra_profit = extra_installs * ltv - extra_spend

    saved_loss = c["baseSpendFen"] * (1000 - roas) // 1000 if verdict == "STOP" else 0
    return dict(c, roasPermille=roas, verdict=verdict, scaleStarPermille=scale_star,
                extraSpendFen=extra_spend, extraProfitFen=extra_profit, savedLossFen=saved_loss)


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每渠道一根横条——决策带来的金额改善(加投增量利润 / 止损省下的亏损)。"""
    W, H, PAD, ROW = 760, 70 + 56 * len(rows), 200, 56
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}
                  ).text = "《合成小岛》止损与加投的金额改善(分):绿=加投增量利润,蓝=止损省下的亏损"
    span = W - PAD - 24
    hi = max([max(r["extraProfitFen"], r["savedLossFen"]) for r in rows] + [1])
    color = {"SCALE": "#16a34a", "STOP": "#2563eb", "HOLD": "#cbd5e1"}
    for i, r in enumerate(rows):
        y = 60 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 24), "font-size": "12",
                                     "fill": "#374151"}).text = f'{r["name"]} · {r["verdict"]}'
        amt = r["extraProfitFen"] if r["verdict"] == "SCALE" else r["savedLossFen"]
        w = span * amt // hi
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 8), "width": str(w), "height": "20",
                                     "rx": "3", "fill": color[r["verdict"]]})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 23), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'ROAS {r["roasPermille"]}‰ · {amt}分'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    with open(os.path.join(args.config, "o14_decision.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    rows = [evaluate(c, cfg["stopFloorPermille"], cfg["scaleFloorPermille"], cfg["maxScalePermille"])
            for c in cfg["channels"]]
    total_extra = sum(r["extraProfitFen"] for r in rows)
    total_saved = sum(r["savedLossFen"] for r in rows)
    net_gain = total_extra + total_saved

    digest = ",".join(f'{r["id"]}:{r["verdict"]}:{r["roasPermille"]}:{r["scaleStarPermille"]}'
                      f':{r["extraProfitFen"]}' for r in rows) \
        + f'|T:{total_extra}:{total_saved}:{net_gain}'

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o14_decision.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "stopFloorPermille": cfg["stopFloorPermille"], "scaleFloorPermille": cfg["scaleFloorPermille"],
              "maxScalePermille": cfg["maxScalePermille"], "channels": rows,
              "totalExtraProfitFen": total_extra, "totalSavedLossFen": total_saved,
              "netGainFen": net_gain, "digest": digest,
              "counts": {v: sum(1 for r in rows if r["verdict"] == v) for v in ("STOP", "HOLD", "SCALE")},
              "svg": os.path.join(args.out, "o14_decision.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "o14_decision.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o14_decision.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "baseSpendFen", "cacFen", "ltvFen30", "roasPermille", "verdict",
                "scaleStarPermille", "extraSpendFen", "extraProfitFen", "savedLossFen")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"channels": [{"id": r["id"], "verdict": r["verdict"],
                                    "roasPermille": r["roasPermille"],
                                    "scaleStarPermille": r["scaleStarPermille"],
                                    "extraProfitFen": r["extraProfitFen"],
                                    "savedLossFen": r["savedLossFen"]} for r in rows],
                      "totalExtraProfitFen": total_extra, "totalSavedLossFen": total_saved,
                      "netGainFen": net_gain, "digest": digest,
                      "counts": report["counts"]}, ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/o14_decision.json。四条渠道覆盖了三种动作,scaleDriftPermille 是各自的边际成本斜率:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "stopFloorPermille": 1000,
  "scaleFloorPermille": 1300,
  "maxScalePermille": 3000,
  "note": "止损与再投入决策参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。baseSpendFen=该渠道当前月投放额(分);cacFen=当前单用户获取成本(分);ltvFen30=单用户 D30 收入(分);scaleDriftPermille=每多投 1000‰(即 1 倍基数)CAC 上升的千分比(放量边际成本递增)。stopFloorPermille=止损线(ROAS 千分比,低于即停);scaleFloorPermille=加投线(高于即加);maxScalePermille=单渠道放量倍数上限。真实出价与真实边际成本曲线需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "channels": [
    { "id": "feed", "name": "信息流", "baseSpendFen": 4200000, "cacFen": 381, "ltvFen30": 333, "scaleDriftPermille": 120 },
    { "id": "kol", "name": "达人", "baseSpendFen": 3600000, "cacFen": 553, "ltvFen30": 688, "scaleDriftPermille": 90 },
    { "id": "rewarded", "name": "激励", "baseSpendFen": 2100000, "cacFen": 228, "ltvFen30": 209, "scaleDriftPermille": 60 },
    { "id": "brand", "name": "品牌专区", "baseSpendFen": 1500000, "cacFen": 300, "ltvFen30": 420, "scaleDriftPermille": 400 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o14_decision.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

渠道ROAS‰动作最优倍数金额改善(分)
信息流874STOP—+529200
达人1244HOLD—0
激励916STOP—+176400
品牌专区1400SCALE2.0×+249720
合计2 停 / 1 留 / 1 加+955320

读这张表要读出三件事。第一,两条 ROAS 不到 1000‰ 的渠道直接止损:信息流(874‰)停掉省下 529200 分,激励(916‰)停掉省下 176400 分,合计 705600 分——这些是「不动作就会继续亏掉」的钱。第二,达人 1244‰ 落在止损线与加投线之间,判 HOLD:它能赚(14.1 里贡献毛利 +110 分),但 ROAS 没到加投线 1300‰,先维持观察,不急着加钱。第三,只有品牌专区够格加投:ROAS 1400‰,scaleStar = 1000 + (1400−1000)×1000//400 = 2000,即加到 2.0 倍——不是无限加,而是加到边际 ROAS 落到 1000‰ 那一点;这一步增量投入 1500000 分,增量利润 +249720 分。三个动作合起来,这一轮净改善 955320 分(约 9553 元)。注意 maxScalePermille=3000 是硬上限:哪怕某渠道 ROAS 高到算出 5 倍,也只能加到 3 倍,避免单一渠道过度集中。

P 层内核:跨语言对拍

下面这段是 P 层 TypeScript 内核 core/src/o14/decision.ts——与 sim/o14_decision.py 逐位等价的对偶实现:同样的判定边界、同样的最优倍数求解、同样的增量利润口径。它读同一份 config/o14_decision.json,产出逐字符一致的摘要。

// file: core/src/o14/decision.ts
// tier: P
// verify: node --test 'core/test/o14_*.test.ts'
//
// 止损与再投入决策内核:与 sim/o14_decision.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o14_decision.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface DecisionChannel {
  readonly id: string; readonly name: string;
  readonly baseSpendFen: number; readonly cacFen: number; readonly ltvFen30: number;
  readonly scaleDriftPermille: number;
}
export interface DecisionConfig {
  readonly version: number; readonly game: string;
  readonly stopFloorPermille: number; readonly scaleFloorPermille: number;
  readonly maxScalePermille: number; readonly channels: readonly DecisionChannel[];
}
export type Verdict = "STOP" | "HOLD" | "SCALE";
export interface DecisionResult {
  readonly id: string; readonly verdict: Verdict;
  readonly roasPermille: number; readonly scaleStarPermille: number;
  readonly extraSpendFen: number; readonly extraProfitFen: number; readonly savedLossFen: number;
}
export interface DecisionTotals {
  readonly totalExtraProfitFen: number; readonly totalSavedLossFen: number; readonly netGainFen: number;
}

export function evaluate(c: DecisionChannel, stopFloor: number, scaleFloor: number, maxScale: number): DecisionResult {
  const cac = c.cacFen, ltv = c.ltvFen30;
  const roasPermille = Math.floor((ltv * 1000) / cac);
  const verdict: Verdict = roasPermille < stopFloor ? "STOP"
    : roasPermille >= scaleFloor ? "SCALE" : "HOLD";

  let scaleStarPermille = 1000;
  let extraSpendFen = 0;
  let extraProfitFen = 0;
  if (verdict === "SCALE") {
    const drift = c.scaleDriftPermille;
    scaleStarPermille = Math.min(maxScale, 1000 + Math.floor(((roasPermille - 1000) * 1000) / drift));
    const cacEnd = Math.floor((cac * (1000 + Math.floor((drift * (scaleStarPermille - 1000)) / 1000))) / 1000);
    const avgCac = Math.floor((cac + cacEnd) / 2);
    extraSpendFen = Math.floor((c.baseSpendFen * (scaleStarPermille - 1000)) / 1000);
    const extraInstalls = Math.floor(extraSpendFen / avgCac);
    extraProfitFen = extraInstalls * ltv - extraSpendFen;
  }

  const savedLossFen = verdict === "STOP" ? Math.floor((c.baseSpendFen * (1000 - roasPermille)) / 1000) : 0;
  return { id: c.id, verdict, roasPermille, scaleStarPermille, extraSpendFen, extraProfitFen, savedLossFen };
}

export function evaluateAll(cfg: DecisionConfig): DecisionResult[] {
  return cfg.channels.map((c) => evaluate(c, cfg.stopFloorPermille, cfg.scaleFloorPermille, cfg.maxScalePermille));
}

export function totals(rows: readonly DecisionResult[]): DecisionTotals {
  const totalExtraProfitFen = rows.reduce((s, r) => s + r.extraProfitFen, 0);
  const totalSavedLossFen = rows.reduce((s, r) => s + r.savedLossFen, 0);
  return { totalExtraProfitFen, totalSavedLossFen, netGainFen: totalExtraProfitFen + totalSavedLossFen };
}

/** 跨语言对拍摘要:与 sim/o14_decision.py 的 digest 逐字符一致。 */
export function digest(rows: readonly DecisionResult[], t: DecisionTotals): string {
  const per = rows.map((r) => `${r.id}:${r.verdict}:${r.roasPermille}:${r.scaleStarPermille}:${r.extraProfitFen}`)
    .join(",");
  return `${per}|T:${t.totalExtraProfitFen}:${t.totalSavedLossFen}:${t.netGainFen}`;
}

配套测试 core/test/o14_decision.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验「STOP 省下的亏损按缺口计」「最优倍数落在边际 ROAS=1000 处」两条结论。跑 node --test 'core/test/o14_*.test.ts'(本节 5 个用例,与 14.1 合计 11 个全过),两套独立实现给出同一个摘要:

digest = feed:STOP:874:1000:0,kol:HOLD:1244:1000:0,rewarded:STOP:916:1000:0,brand:SCALE:1400:2000:249720|T:249720:705600:955320

只要有一边的判定边界、倍数求解、均值口径写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真与 TS 内核里必须长得一模一样。

X 层:真实边际成本曲线不可复现

上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的边际成本曲线:scaleDriftPermille 的线性假设是简化,真实放量的 CAC 可能前缓后陡,拐点位置只有投了才知道;
  • 真实的止损与加投线(stopFloor / scaleFloor)应随品类与现金流压力调整,不是固定常数;
  • 真实的放量执行约束:平台单账户预算上限、审核与学习期都会限制「一次性加到 2 倍」的可行性。

正确做法是把这张表当决策底稿:模型给出「停哪些、加哪个、加到几倍」的初判,真实投放时用小步爬坡(见 2.2)逐档验证边际 ROAS 是否真如模型所料。这也是 14.1 / 14.2 的账必须持续被线上数据反标定的原因——决策的输入错了,输出再自洽也是错的。

自检表

检查项合格标准
三动作齐全止损 / 维持 / 加投都有明确判据
边际成本放量推高 CAC 显式建模,不按线性外推
最优点加投倍数解到「边际 ROAS=回本线」,不凭感觉
上下限有 maxScale 硬上限,防单渠道过度集中
金额可汇总止损省下的亏损与加投增量利润都能加总成净改善
边界清晰真实边际成本曲线标 X,不写成「实测」

到这里,第 14 章三节连成了一条完整的账:14.1 算一个用户赚不赚,14.2 算一个月盈不盈、现金够不够,14.3 把两者合成停 / 留 / 加的组合决策。下一章进入案例与失败模式——把《合成小岛》的投放与变现复盘一遍,看看这些账在真实操盘里长什么样。

阅读导航:上一节:14.2 月度经营账与现金流 · 下一节:15.1 《合成小岛》投放与变现复盘 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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