コホート mROIC ダッシュボードを組む — 広告費×CRM から「踏む/止める」判定まで
ROAS ダッシュボードは「見る」ためのもので、「いくらまで払ってよいか」には答えません。広告費・リード・CRM 成約を結合し、リード獲得月コホートの mROIC を WACC ハードルと比較して判定を返すダッシュボードの実装を、SQL・集計ロジック・暫定/成熟コホートの扱い・前提値の設計まで動くコードで示します。
by Shin
ROAS ダッシュボードはあるのに、「予算を増やすべきか」には誰も答えられない——なぜでしょうか。
答えは、「見る」ダッシュボードと「判定する」ダッシュボードが別物だからです。前者は媒体の数字を並べます。後者は、コホート × 財務指標 × ハードルレートの3点を組み合わせて「踏む/止める」を返します。この記事では、広告費・リード・CRM 成約の3テーブルから、リード獲得月コホートの mROIC を算出し、WACC 由来のハードルと比較して判定バッジを出すところまでを、動く SQL で通します。
なぜ物差しが ROAS ではなく mROIC × WACC なのか——判断の枠組み自体は、対になるビジネス版(→ ROAS では「いくらで回るか」に答えられない)で扱っています。本稿はその実装側です。筆者は成約までのラグが数ヶ月ある商材の実案件でこの仕組みを運用しており、以下のコードは実装をそのまま写すのではなく、汎用の教科書スキーマに書き直したものです(数値はすべて架空の丸い数字。SQL は DuckDB で実行検証済み)。
全体設計 — 3つのデータと1本の結合キー
必要なデータは3つだけです。
| テーブル | 出どころ | 粒度 | 主要カラム |
|---|---|---|---|
ads_spend | 媒体 API | 日 × 媒体 | date, channel, cost |
leads | フォーム/CRM | リード1行 | lead_id, acquired_at, channel |
deals | CRM | 成約1行 | lead_id, closed_at, gross_profit |
設計の要は、テーブルの数ではなく結合キーが lead_id の1本しかないことです。「このリードがいつ獲得され、その後いくらの成約を生んだか」を辿れるのは、CRM の成約レコードにリードIDが残っている場合だけ。つまりこのダッシュボードは、リードIDが CRM まで貫通している媒体からしか始められません(判明率の問題)。ただしそれで構いません——主力1媒体で回り始めれば投資判定の質は変わりますし、貫通経路を増やす話は計測側の設計問題として別記事(→ コンバージョン API とオフライン CV)で扱っています。
まずスキーマと、検証用のサンプルデータを用意します。実案件の値は出せないため、「単月 PL とコホート評価で結論が逆転する」構造だけを残した架空の丸い数字です(粗利は1件100万円固定、成約ラグは68〜101日)。リードと成約は仕様表から展開して作ります。
create table ads_spend (date date, channel text, cost bigint);
create table leads (lead_id text, acquired_at date, channel text);
create table deals (lead_id text, closed_at date, gross_profit bigint);
-- 広告費: 1〜2月 100万 / 3〜4月 300万(増額) / 5月 60万(削減) / 6月 100万
insert into ads_spend
select month_start, 'search', cost from (values
(date '2025-01-01', 1000000),
(date '2025-02-01', 1000000),
(date '2025-03-01', 3000000),
(date '2025-04-01', 3000000),
(date '2025-05-01', 600000),
(date '2025-06-01', 1000000)
) t(month_start, cost);
-- リード: 月ごとの獲得数を展開する(CPL 5万円前後・絞りすぎた5月だけ 6万円)
insert into leads
select printf('L-%s-%02d', strftime(cohort, '%Y%m'), i) as lead_id,
cohort + (i - 1) % 28 * interval 1 day as acquired_at,
'search' as channel
from (values
(date '2025-01-01', 20), (date '2025-02-01', 20),
(date '2025-03-01', 60), (date '2025-04-01', 60),
(date '2025-05-01', 10), (date '2025-06-01', 20)
) spec(cohort, n_leads), lateral (select unnest(generate_series(1, n_leads)) as i);
-- 成約: 「どのコホートの何件が、どの月に閉じるか」の計画から展開する
insert into deals
with plan as (
select cohort, close_month, unnest(generate_series(1, n)) as j from (values
(date '2025-01-01', date '2025-03-01', 1), (date '2025-01-01', date '2025-04-01', 2),
(date '2025-02-01', date '2025-04-01', 1), (date '2025-02-01', date '2025-05-01', 2),
(date '2025-03-01', date '2025-05-01', 3), (date '2025-03-01', date '2025-06-01', 6),
(date '2025-04-01', date '2025-06-01', 3), (date '2025-04-01', date '2025-07-01', 6),
(date '2025-05-01', date '2025-08-01', 1),
(date '2025-06-01', date '2025-08-01', 1), (date '2025-06-01', date '2025-09-01', 2)
) t(cohort, close_month, n)
),
expanded as (
select cohort, close_month,
row_number() over (partition by cohort order by close_month, j) as k
from plan
),
ranked_leads as (
select lead_id, date_trunc('month', acquired_at) as cohort,
row_number() over (partition by date_trunc('month', acquired_at)
order by acquired_at, lead_id) as k
from leads
)
select l.lead_id, e.close_month + 9 * interval 1 day as closed_at, 1000000 as gross_profit
from expanded e
join ranked_leads l using (cohort, k);これで leads 190行・deals 28行のミニチュアができます。実務では ads_spend は媒体 API の日次取得、leads と deals は CRM からの同期で、いずれも集計層(マート)に正規化して置きます。マートの設計論は連載のmarts 層を作るを前提にしています。
SQL — 単月 PL とコホート、同じデータで結論が逆転する
まず、多くの現場にすでにある「単月 PL ビュー」——その月に使った広告費と、その月に閉じた粗利の比較です。
-- 単月 PL ビュー: その月に使った広告費 vs その月に閉じた粗利
with spend as (
select date_trunc('month', date) as month, sum(cost) as ad_cost
from ads_spend group by 1
),
closed as (
select date_trunc('month', closed_at) as month, sum(gross_profit) as gross
from deals group by 1
)
select
strftime(s.month, '%Y-%m') as month,
s.ad_cost,
coalesce(c.gross, 0) as gross_closed,
round(100.0 * coalesce(c.gross, 0) / s.ad_cost, 0) as monthly_roas_pct -- 粗利ベース
from spend s
left join closed c using (month)
order by 1;出力はこうなります(以下、金額は万円に読み替えて表記。SQL の出力は円)。
| month | 広告費 | 当月に閉じた粗利 | 単月ROAS(粗利) |
|---|---|---|---|
| 2025-01 | 100 | 0 | 0% |
| 2025-02 | 100 | 0 | 0% |
| 2025-03 | 300 | 100 | 33% |
| 2025-04 | 300 | 300 | 100% |
| 2025-05 | 60 | 500 | 833% |
| 2025-06 | 100 | 900 | 900% |
増額した3月が最悪(33%)、削減した5月が絶好調(833%)に見えます。成約ラグがある商材では、単月 PL は費用と成果の時制がずれているからです(1〜2月の 0% も同じ理屈——立ち上げ期は種まきだけが費用計上される)。
次に、費用と成果の時制を揃えたリード獲得月コホートの成果テーブルを組みます。リード月で束ね、そのリードたちがその後に生んだ成約を lead_id 経由で全部帰属させます。
-- コホート成果テーブル: リード獲得月で束ね、その後の成約を帰属させる
with cohort_spend as (
select date_trunc('month', date) as cohort, sum(cost) as ad_cost
from ads_spend group by 1
),
cohort_leads as (
select date_trunc('month', acquired_at) as cohort, count(*) as n_leads
from leads group by 1
),
cohort_deals as (
select date_trunc('month', l.acquired_at) as cohort,
count(*) as n_deals, sum(d.gross_profit) as gross
from deals d
join leads l using (lead_id) -- 結合キーは lead_id ただ1本
group by 1
)
select strftime(s.cohort, '%Y-%m') as cohort,
s.ad_cost, cl.n_leads,
coalesce(cd.n_deals, 0) as n_deals,
coalesce(cd.gross, 0) as gross
from cohort_spend s
left join cohort_leads cl using (cohort)
left join cohort_deals cd using (cohort)
order by 1;| cohort | 広告費 | リード | 成約 | 成約粗利 |
|---|---|---|---|---|
| 2025-01 | 100 | 20 | 3 | 300 |
| 2025-02 | 100 | 20 | 3 | 300 |
| 2025-03 | 300 | 60 | 9 | 900 |
| 2025-04 | 300 | 60 | 9 | 900 |
| 2025-05 | 60 | 10 | 1 | 100 |
| 2025-06 | 100 | 20 | 3 | 300 |
この時点ですでに景色が違います。3月コホートは 300万の広告費で 900万の粗利を生んでおり(最良)、5月コホートは 60万で 100万——絞った月ほど良く見えた単月 PL と逆です。判定まで落とすには、ここに財務ロジックを載せます。
財務ロジック — mROIC・ハードル・限界CAC
載せる計算は3本だけです。
- mROIC =(成約粗利 − 総CAC)÷ 総CAC。総CAC = 広告費 + 割り当てコスト(営業人件費・ツール費を分け入れたもの)
- ハードル率 = WACC × 回収日数 ÷ 365(年率の資本コストを回収期間に換算した基準線)
- 限界CAC = 平均粗利 ÷(1 + ハードル率)= 1件の成約に払ってよい獲得コストの上限
ここで割り当てを省略してはいけません。等式で書くと理由が明確になります:割り当てゼロの mROIC は「粗利ベース ROAS − 100%」と恒等です(例:3月コホートは粗利ROAS 300% で、割り当てゼロの mROIC はちょうど 200%)。割り当てを入れてはじめて、ROAS の付け替えではない新しい情報——広告費以外も含めて資本が回っているか——が数字に入ります。
先ほどのコホート成果テーブルに、前提値の CTE(params)と判定を足します。
-- コホート mROIC ビュー: 財務ロジックと3値判定を載せる
with params as (
select
0.10 as wacc, -- 年率(架空の例示値)
120 as payback_days, -- 回収期間 = 成熟日数(架空・90〜150日級の商材想定)
1000000 as alloc_monthly, -- 広告費以外の割り当て(営業人件費・ツール等/月・架空)
date '2025-11-01' as as_of -- 評価日
),
cohort_leads as (
select date_trunc('month', acquired_at) as cohort, count(*) as n_leads
from leads group by 1
),
cohort_deals as (
select date_trunc('month', l.acquired_at) as cohort,
count(*) as n_deals,
sum(d.gross_profit) as gross
from deals d
join leads l using (lead_id)
join params p on d.closed_at <= p.as_of -- 評価日までに閉じた成約のみ
group by 1
),
cohort_spend as (
select date_trunc('month', date) as cohort, sum(cost) as ad_cost
from ads_spend group by 1
)
select
strftime(s.cohort, '%Y-%m') as cohort,
s.ad_cost + p.alloc_monthly as total_cac,
cl.n_leads,
coalesce(cd.n_deals, 0) as n_deals,
coalesce(cd.gross, 0) as gross,
round(100.0 * (coalesce(cd.gross, 0) - (s.ad_cost + p.alloc_monthly))
/ (s.ad_cost + p.alloc_monthly), 1) as mroic_pct,
round(100.0 * p.wacc * p.payback_days / 365, 1) as hurdle_pct,
case
when s.cohort + interval 1 month + p.payback_days * interval 1 day > p.as_of
then '暫定'
when (coalesce(cd.gross, 0) - (s.ad_cost + p.alloc_monthly))
/ (s.ad_cost + p.alloc_monthly)
>= p.wacc * p.payback_days / 365 then '超過'
else '毀損'
end as verdict
from cohort_spend s
join params p on true
left join cohort_leads cl using (cohort)
left join cohort_deals cd using (cohort)
order by 1;評価日 2025-11-01(全コホート成熟後)の出力です。
| cohort | 総CAC | リード | 成約 | 粗利 | mROIC | ハードル | 判定 |
|---|---|---|---|---|---|---|---|
| 2025-01 | 200 | 20 | 3 | 300 | 50.0% | 3.3% | 超過 |
| 2025-02 | 200 | 20 | 3 | 300 | 50.0% | 3.3% | 超過 |
| 2025-03 | 400 | 60 | 9 | 900 | 125.0% | 3.3% | 超過 |
| 2025-04 | 400 | 60 | 9 | 900 | 125.0% | 3.3% | 超過 |
| 2025-05 | 160 | 10 | 1 | 100 | −37.5% | 3.3% | 毀損 |
| 2025-06 | 200 | 20 | 3 | 300 | 50.0% | 3.3% | 超過 |
単月 PL と結論が両方向に逆転しました。単月 ROAS 33% で「失敗」に見えた3月の増額は mROIC 125% の超過(最良の投資)、単月 ROAS 833% で「大成功」に見えた5月の削減は mROIC −37.5% の毀損です。5月が毀損になる構造も表から読めます——広告費を60万まで絞っても割り当ての100万は固定で残るため、獲得規模が小さすぎると、割り当てたぶんに負けます。固定費を割り当ててはじめて見える判定です。
限界CAC も同じ params から1行で出ます:1000000 / (1 + 0.10 * 120 / 365) ≒ 96.8万円。この例では成約1件あたりの実際の総CAC は 44〜160万円に散らばっており、毀損コホート(5月:160万円)だけが上限を超えています。
なお、この計算は BI ツールの計算フィールドに書かないでください。mROIC の定義が Looker Studio・Metabase・スプレッドシートに三重に存在して食い違う、という事故は「秘伝のタレ SQL」問題の再演です。財務ロジックは集計層(このビュー、または薄い dbt モデル)に一度だけ置き、BI は表示に徹します。
暫定と成熟 — 未完成のコホートをどう表示するか
上の verdict には3値目——暫定——が入っています。これはこのダッシュボードで最も事故を防いでいる部分です。
コホートは「リード獲得から成熟日数(この例では120日)が経つまで、成約が積み上がる途中」です。未成熟のコホートの mROIC は必ず低く出て、しかも成約が閉じるたびに改善する方向のバイアスを持ちます。同じクエリを評価日 2025-08-01 で実行すると:
| cohort | 粗利 | mROIC | 判定 |
|---|---|---|---|
| 2025-01 | 300 | 50.0% | 超過 |
| 2025-02 | 300 | 50.0% | 超過 |
| 2025-03 | 900 | 125.0% | 超過 |
| 2025-04 | 900 | 125.0% | 暫定 |
| 2025-05 | 0 | −100.0% | 暫定 |
| 2025-06 | 0 | −100.0% | 暫定 |
5〜6月コホートは成約がまだ1件も閉じておらず mROIC −100% と表示されますが、判定は毀損ではなく暫定です。この区別を実装しないと、「直近月が壊滅している」という誤読が毎月・確実に起きます(4月コホートのように、暫定のまますでにハードルを超えている場合もある——それでも成熟までは確定判定にしません)。判定条件は1行です:そのコホートの最後のリードから payback_days が経過していなければ暫定。成熟日数そのものは、成約ラグ(closed_at - acquired_at)の分布の上側パーセンタイルから決めて params に置きます。
前提値は調整可能にする — WACC・割り当て・粗利率
params CTE に置いた WACC・回収日数・割り当て額は、どれも「正確な値」が存在しない数字です。WACC は財務に聞いても幅で返ってきますし、割り当ては按分ルールの決めの問題です。ここで精密さを追うとダッシュボードは永遠に完成しません。
実案件で正解だったのは逆の設計です——丸い暫定値で回し始め、前提値を画面から調整可能(保存付き)にする。実装は、params をハードコードから1行テーブル(またはアプリの設定ストア)に昇格させ、UI のフォームで上書きできるようにするだけです。ダッシュボード本体の実装パターンは姉妹編(→ Supabase × Next.js で作るマーケティングダッシュボード)と同じ構成で足ります。
前提値を動かせることには、運用開始を早める以上の価値があります。「WACC を8%から12%に振っても判定が変わらない」ことが見えれば、判定は前提の精度に対して頑健だと言えます。逆に判定が反転するなら、そこだけ財務と詰めれば済みます。感度が見えること自体が意思決定の材料になります。
データ品質側にも同種の「決めの問題」があります。たとえば CRM の成約レコードに粗利額が入っていない行。実案件では、欠損行は裾を刈った(winsorized)平均値で補完し、補完している事実を注記として画面に出す、という設計判断にしました。補完値そのものより、「この数字には補完が混ざっている」が見えていることが重要です。
判定の表示 — 3値バッジと「n が小さい月」の扱い
表示側の設計は2点だけ守ります。
判定は3値バッジで出す。 超過/毀損/暫定を、mROIC の数字より先に目に入る形(色付きバッジ)で置きます。このダッシュボードの利用者が持ち帰るのは 125.0% という数字ではなく「3月型の増額は踏んでよい」という判定だからです。暫定バッジは灰色・破線など「未確定」が伝わる見た目にします。
注意書きは README ではなく画面に置く。 成約 n が数件のコホートは、1件の増減で mROIC が数十ポイント振れます(この例の5月コホートは n=1。もう1件閉じれば −37.5% から +25% に跳ねます)。「n が5未満の月の率は参考値」という注記を、ドキュメントではなくセルの隣に常時表示します。winsorized 補完の注記も同様です。見る人は README を読みません——画面に書いていないルールは、存在しないのと同じです。
まとめ
「見る」ダッシュボードから「判定する」ダッシュボードへの距離は、テーブル3枚・結合キー1本・計算3本と、暫定/成熟の規律だけでした。技術的には難しいところがないぶん、価値は財務ロジックの置き場所(集計層に一度だけ)と、誤読を防ぐ表示設計(暫定バッジ・画面上の注記)に宿ります。
そして、この判定の物差しがなぜ ROAS ではなく mROIC × WACC なのか——単月 PL がアクセルとブレーキを逆に踏ませる構造、資本コストという基準線の意味は、対になるビジネス版で整理しています。