経営会議や事業計画の資料を作るたびに、競合数社の決算短信や有価証券報告書を開き、売上高や利益をExcelに書き写していないでしょうか。転記には時間がかかり、決算期や会計基準の違いを見落とすと、比べてはいけない数字を並べてしまいます。
この連載「分析編」では、ZENITH CodeのAPIで、競合調査や比較分析に使える自社用の画面を作ります。初回は、自社と競合最大5社の収益性と効率を1枚に並べ、さらに同じ業種の上場会社全体の分布の中で、どのあたりにいるかを見られる「比較ボード」を読みます。
画面の数値は、2026年10月29日にZENITH Codeの実データを取得して保存したデモです。トヨタ自動車(7203)を自社に見立て、同業5社と並べています。「自社」の札はデモ上の見本です。
私たち株式会社Crabgrassは、企業の財務データなどをAPIで提供するZENITH Codeを開発しています。本記事は自社サービスのデータを使うためのテンプレートを題材にした実装例です。
この記事のコードは、公開サンプル集のpeer-benchmarkテンプレート(執筆時点の版)に対応しています。画面の仕組みの基本(ローカルのPythonサーバーとブラウザの役割分担)は、実践編で詳しく扱いました。
1. この回で読む画面と操作
| 操作 | 画面で変わるもの | 今回読むコード |
|---|---|---|
| 自社のコードを入れて「競合候補を表示」を押す | 同じ業種で規模の近い会社が候補に並ぶ |
server.py の fetch_peers と peer_rows
|
| 「比較データを取得」を押す | 収益性と効率の表、業種の分布帯 |
fetch_bundle、model.mjs の roe・roa・revenueCagr3y
|
| 表の数字を確かめる | 会社名の下の決算期と会計基準 | companyMetrics |
| 「印刷・PDF」「CSVで保存」を押す | A4横の資料、比較表のファイル |
style.css の印刷設定、comparisonCsv
|
2. 登録不要で試す
必要なものはPython 3.9以上とブラウザです。追加のパッケージはありません。
git clone https://github.com/Crab-grass/zenith-code-examples.git
cd zenith-code-examples
python3 templates/peer-benchmark/server.py
ブラウザで http://127.0.0.1:8790 を開くと、保存済みのデモが表示されます。デモではAPIを呼ばないので、会員登録もAPIキーも要りません。終了するときは端末で Ctrl+C を押します。
3. 比較先の候補は、名前とコードだけ受け取る
比較先を毎回自分で探すのは手間です。ZENITH Codeの /stable/peers は、指定した会社と同じ東証33業種で、規模の近い会社を返します。
このAPIの応答には、時価総額やPER、配当利回りのような株価から計算する値も含まれます。比較ボードは財務諸表と開示の数値だけで作る画面なので、ローカルのサーバーで必要な項目だけを残してから、ブラウザへ渡しています。
# Whitelists: anything not listed here never reaches the browser. /peers and
# /industry-benchmarks also return price-derived fields (price, marketCap, PER,
# PBR, dividend yield, ...); this template must not display them.
PEER_FIELDS = ("role", "symbol", "nameJa", "nameEn", "sector")
BENCHMARK_FIELDS = ("sector", "sectorJa", "sectorEn", "count") + tuple(
metric + suffix for metric in ("roe", "roa", "revenueCagr3y", "epsCagr3y")
for suffix in ("Count", "Q1", "Median", "Q3"))
「使わない項目を消す」のではなく「使う項目だけを書き出す」形にしているのは、APIに項目が増えたときも、意図しない値が画面に出ないようにするためです。
候補の行では、対象の会社そのものと、業種の中央値の行も取り除きます。
peers = [r for r in picked if r["role"] == "peer" and isinstance(r["symbol"], str)
and SYMBOL.fullmatch(r["symbol"]) and not _same(r["symbol"], expected)]
# The sector-median row carries market-based figures only; it is dropped.
return {"target": target, "peers": peers[:5]}
候補は出発点です。実際の競合は、事業の重なりで決まります。画面ではチェックを外したり、別の証券コードを足したりして、合計6社まで選べます。
4. 各社の最新通期を並べる
「比較データを取得」を押すと、サーバーは1社ずつ順番に、損益計算書・貸借対照表・キャッシュフロー計算書などを取得します。
company["income"] = statement_rows(requester("/income-statement", {
"symbol": symbol, "period": "annual", "limit": 4}, api_key), symbol, INCOME_FIELDS, 4)
company["balance"] = statement_rows(requester("/balance-sheet-statement", {
"symbol": symbol, "period": "annual", "limit": 1}, api_key), symbol, BALANCE_FIELDS, 1)
表に並ぶのは、各社の**最新の通期(FY)**です。会社名の下に、決算期と会計基準を必ず出しています。
トヨタ自動車
7203 · FY2026-03 · IFRS
今回の6社はどれも3月決算ですが、比較先に12月決算の会社を入れると、同じ列に9か月ずれた期間の数字が並びます。会計基準がIFRSと日本基準で違えば、営業利益の範囲も同じではありません。表の数字だけを見て判断しないよう、会社名の下を毎回確かめてください。
5. ROEとROAは、この画面で期末の値から計算する
ROE(自己資本利益率)とROA(総資産利益率)は、取得した損益計算書と貸借対照表から、model.mjs で計算しています。
const samePeriod = (a, b) => Boolean(a && b && a.fiscalPeriod === b.fiscalPeriod);
// Period-end values, not averages. Zero or negative denominators give no ratio.
export function roe(income, balance) {
if (!samePeriod(income, balance)) return null;
const top = finite(income.netIncomeAttributableToOwners);
const equity = finite(balance.ownersEquity) ?? finite(balance.shareholdersEquity);
return top === null || equity === null || equity <= 0 ? null : top / equity;
}
export function roa(income, balance) {
if (!samePeriod(income, balance)) return null;
const top = finite(income.netIncome), assets = finite(balance.totalAssets);
return top === null || assets === null || assets <= 0 ? null : top / assets;
}
ポイントは3つです。
- 分母は期末の値です。期首と期末の平均を使う方法もありますが、そのためには貸借対照表を2期分取得する必要があります。この画面はリクエスト数を抑えるため1期分だけ取得し、表の見出しに「期末値で計算」と書いています。
- 損益計算書と貸借対照表の決算期が違えば計算しません。片方だけ新しい決算が反映されている時期に、違う期の数字で割り算をしないためです。
- 分母が0以下なら計算しません。債務超過の会社でROEを計算すると、赤字なのにプラスの値が出ることがあるためです。
計算できない場合は null を返し、画面には「—」と表示します。0とは扱いません。
6. 3年成長率は、条件がそろうときだけ出す
売上高の3年成長率(年平均)は、4通期分の売上高から計算します。ただし、次の条件がすべてそろうときだけです。
// Three-year CAGR needs four consecutive ordinary fiscal years with the same
// fiscal month and the same accounting standard at both ends.
export function revenueCagr3y(years) {
if (!Array.isArray(years) || years.length < 4) return null;
const rows = years.slice(-4);
for (let i = 0; i < rows.length; i++) {
const days = finite(rows[i].fiscalPeriodDays);
if (days === null || days < 360 || days > 366) return null;
if (i && (rows[i].fiscalPeriod.slice(5) !== rows[0].fiscalPeriod.slice(5) ||
Number(rows[i].fiscalPeriod.slice(0, 4)) - Number(rows[i - 1].fiscalPeriod.slice(0, 4)) !== 1)) return null;
}
const [base, , , last] = rows;
if (!base.accountingStandard || base.accountingStandard !== last.accountingStandard) return null;
const a = finite(last.revenue), b = finite(base.revenue);
return a === null || b === null || b <= 0 || a < 0 ? null : (a / b) ** (1 / 3) - 1;
}
| 条件 | 外れる例 |
|---|---|
| 連続する4通期がある | 上場して間もない、途中の年が欠けている |
| 各期が通常の1年(360〜366日) | 決算月を変えた年の変則決算(9か月など) |
| 決算月が同じ | 3月決算から12月決算に変えた |
| 両端の会計基準が同じ | 日本基準からIFRSに移った |
デモでは、スズキの成長率が「—」になっています。取得した4通期のうち、2023年3月期と2024年3月期が日本基準、2025年3月期からがIFRSで、最初と最後の年の会計基準が違うためです。基準が変わると売上高の範囲が変わることがあり、その差まで「成長」に含めてしまいます。
表に空欄が出ると見栄えは悪くなりますが、条件が違う数字を年平均の成長率として並べるより、「比べられない」と示すほうが会議では安全です。
7. 業種の分布の中で見る
競合5社と比べるだけでは、「業界全体の中でどうか」が分かりません。6社とも低ければ、その中で1位でも業界では下のほうかもしれません。
そこで、/stable/industry-benchmarks から、自社と同じ業種の上場会社全体の四分位(Q1・中央値・Q3)を取得し、帯として重ねています。
| 用語 | 意味 |
|---|---|
| Q1(第1四分位) | 小さいほうから25%の位置の値 |
| 中央値 | ちょうど真ん中の値 |
| Q3(第3四分位) | 小さいほうから75%の位置の値 |
| 帯(Q1〜Q3) | 業種の真ん中の半分の会社が入る範囲 |
APIから受け取った四分位は、そのまま描かずに確かめます。
const BENCHMARK_METRICS = ["roe", "roa", "revenueCagr3y"];
export function normalizeBenchmark(row, sector) {
if (!row) return null;
if (row.sector !== sector) fail();
const out = { sector: row.sector, sectorJa: row.sectorJa ?? null, sectorEn: row.sectorEn ?? null,
count: finite(row.count) };
for (const metric of BENCHMARK_METRICS) {
const q1 = finite(row[metric + "Q1"]), median = finite(row[metric + "Median"]), q3 = finite(row[metric + "Q3"]);
// A band is drawn only when the quartiles are complete and ordered.
out[metric] = q1 !== null && median !== null && q3 !== null && q1 <= median && median <= q3
? { q1, median, q3, count: finite(row[metric + "Count"]) } : null;
}
return out;
}
別の業種の行が返ってきたら全体を中止し、3つの値のどれかが欠けている、または Q1 ≤ 中央値 ≤ Q3 の順になっていない指標は、帯を描きません。帯の下には、何社の分布かも出しています(デモの輸送用機器では、ROEとROAが79社、成長率が78社)。
帯の読み方
デモのROEの帯は、Q1 3.3%・中央値5.7%・Q3 9.9%です。トヨタ自動車のROEは9.6%で、帯の右端の近く、つまり業種の中では上位寄りにいます。売上高の3年成長率は10.9%で、帯(Q3 8.5%)の右の外側です。
読むときは、次の2点に注意してください。
- 算定方法が同じとは限りません。帯はZENITH Codeが業種の全社について計算した値で、ROE・ROAが100%を超える会社などは外れ値として除いています。この画面の点は、前の節のとおり期末の値で計算しています。帯は「業界の水準」をつかむための目安として使います。
- 帯は自社の業種で取得します。比較先に別の業種の会社を入れた場合、その会社にとっての業種の分布ではありません。
8. 会議資料にする
「印刷・PDF」を押すと、ボタン類を除いたA4横のレイアウトで印刷します。画面をダーク表示にしていても、印刷は常に明るい配色です。
「CSVで保存」では、比較表をファイルにします。CSVは表示用に丸めた文字ではなく、元の値を書き出します。
- 金額は円単位、比率は小数(0.096 = 9.6%)
- 値がないところは空欄(0にしない)
- 業種のQ1・中央値・Q3も行として書き出す
表計算ソフトで開いたときに、= や - で始まる文字が数式として実行されないよう、先頭にアポストロフィを付けています。
// CSV cells are always quoted; text that a spreadsheet would treat as a formula
// gets a leading apostrophe. Missing values are blank cells, never zero.
export function csvCell(value) {
if (value === null || value === undefined || (typeof value === "number" && !Number.isFinite(value))) return '""';
const text = typeof value === "string" ? value.replace(/^[=+\-@\t\r]/, "'$&") : String(value);
return '"' + text.replaceAll('"', '""') + '"';
}
社内の資料に使う場合も、出典の表示が必要です。画面には取得日時と各社の出典(書類ID)を、CSVには書類IDを入れています。共有するときはデータの表示ルールを確認してください。
9. 自社のコードで動かす
会員登録をしてAPIキーを作成し、--live を付けて起動し直します。
python3 templates/peer-benchmark/server.py --live
キーは端末の非表示入力で渡します。キーはローカルのPythonプロセスだけが持ち、ブラウザには送りません。
画面で「実データ」を選び、自社の証券コードを入れて「競合候補を表示」を押します。比較先を選んで「比較データを取得」を押すと、1社あたり6リクエスト、業種の分布に1リクエストを使います。6社なら37リクエストです。
| 動き | 理由 |
|---|---|
| 1社ずつ順番に取得する(並列にしない) | 利用枠を一度に使い切らないため |
| 1件でも失敗したら比較全体を中止する | 一部の会社だけ古い、欠けた状態の表を会議に出さないため |
| 自動の再試行・定期更新はしない | 押した回数以上にリクエストしないため |
定期的に自動で更新する仕組みは、連載の後の「運用編」で扱います。
10. 小さな変更:業種の上位25%・下位25%に印を付ける
帯の中の点の位置は、グラフをよく見ないと分かりません。会議の資料では、数字の横に印があると一目で伝わります。値がQ3以上なら「▲」、Q1未満なら「▼」を付けてみます。
1. 判定する関数を足す(model.mjs)。 roe() の下に足します。
// Where a value sits against the sector quartiles: top quarter, bottom quarter, or between.
export function bandZone(value, band) {
value = finite(value);
if (value === null || !band) return null;
return value >= band.q3 ? "high" : value < band.q1 ? "low" : "mid";
}
値がない、または業種の分布がない場合は null を返し、印を付けません。
2. 読み込む名前に足す(app.mjs の先頭)。
segmentMix, bandZone, domain, barGeometry, position, niceAxis, oku, percent, number, salary, comparisonCsv,
3. 数字の横に印を付ける(app.mjs の renderBands())。 値を表示している行を次のように変えます。
const zone = bandZone(row[metric], band);
const mark = zone === "high" ? " ▲" : zone === "low" ? " ▼" : "";
line.append(node("span", nameOf(row.symbol), { class: "bn", title: nameOf(row.symbol) }), track,
node("span", percent(row[metric], lang) + mark, { class: "bv" + (row[metric] === null ? " missing" : "") + (zone ? " " + zone : "") }));
4. 凡例を足す(app.mjs)。 texts の ja と en に文言を足し、凡例を作るループの後に2行目を足します。
bandKeyHigh: "▲ Q3以上(業種の上位25%)", bandKeyLow: "▼ Q1未満(業種の下位25%)",
bandKeyHigh: "▲ At or above Q3 (sector top 25%)", bandKeyLow: "▼ Below Q1 (sector bottom 25%)",
for (const label of ["bandKeyHigh", "bandKeyLow"]) key.append(node("span", t(label), { class: "key-item" }));
wrap.append(key);
5. 色と幅を整える(style.css)。 数字の列に印のぶんの幅を足し、色を付けます。スマホ幅(@media の中の .band-row)も同じように広げます。
.band-row { display: grid; grid-template-columns: 8.6em minmax(0, 1fr) 4.8em; gap: 10px; align-items: center; height: 27px; }
.band-row .bv.high { color: var(--ok); }
.band-row .bv.low { color: var(--neg); }
数字の列を広げたぶん、グラフの幅が少し狭くなり、ROAの目盛り(-5%〜7.5%の6本)が詰まって見えます。renderBands() の目盛りを間引く条件を、6本から間引くように変えます。
const step = axis.ticks.length > 5 ? 2 : 1;
保存して再読み込みし、デモで本田技研工業のROEに「▼」、トヨタ自動車の成長率に「▲」が付けば完成です。色だけでなく記号でも示しているので、白黒で印刷しても区別できます。
11. 確認箇所
| 症状 | 確認箇所 |
|---|---|
| ROE・ROAが「—」になる | 損益計算書と貸借対照表の決算期が同じか、自己資本が0以下ではないか |
| 成長率が「—」になる | 4通期がそろっているか、変則決算・決算月の変更・会計基準の変更がないか |
| 帯が表示されない | 業種の分布の3つの値がそろい、順番どおりか(「業種の分布なし」と出る) |
| 決算期の違う会社が並んでいる | 会社名の下の決算期。月が違う会社は期間がずれている |
| 印が付かない |
bandZone を import に足したか、業種の分布が取得できているか |
| CSVを開くと数値の桁が違う | 金額は円単位、比率は小数で書き出している |
表示を変える作業をAIに頼む場合も、計算やAPIの呼び出しに触れないことを明記すると、確認する範囲を絞れます。
peer-benchmarkテンプレートの業種の分布帯で、値がQ3以上なら「▲」、Q1未満なら「▼」を数字の横に付けてください。
判定は model.mjs に bandZone(value, band) として足し、Q1・Q3がない場合や値がない場合は印を付けないでください。
app.mjs の renderBands と凡例、texts の ja と en、style.css の数字の列の幅と色を変更します。
ROE・ROA・成長率の計算、server.py、APIの呼び出し回数は変更しないでください。
nullを0に変換しないでください。
変更箇所と、デモで確認する操作を説明してください。
12. 次回は事業別に比べる
今回は会社全体の収益性を、業種の分布と並べて見ました。ただ、同じ自動車メーカーでも、二輪や金融の事業を持つかどうかで、会社全体の利益率の意味は変わります。
次回のZENITH Code分析②では、各社が開示するセグメント情報を並べ、競合がどの事業で稼いでいるかを比べます。区分が会社ごとに違うセグメントを、どう並べれば誤読しないかも扱います。


