ビートモニタリングをフォワードPER軸に刷新する
朝、ビートモニタリングのカードを見ながら手が止まった。「売上ビート率」「YoY」「翌日株価」の3指標が並んでいるのに、結局この銘柄が割安なのか割高なのかが一目で分からない。決算の瞬間風速は見えても、いま株を買うべきかの判断材料になっていない。そこで指標を全部入れ替えることにした。直近株価 + NTM EPS(次の4四半期予想の合計)+ フォワードPER。この3つに刷新して、最終的に成長率 × フォワードPER の散布図ページまで一気に通した。
まず計画を固める
いきなり手を動かさず、プランモードで要件を確定させた。
- カード指標を入れ替える(売上ビート/YoY/翌日株価 → 直近株価/NTM EPS/フォワードPER)
- NTM EPS は 次の4四半期(fq0〜3)の
eps_adj予想の合計 としてconsensus_estimatesから算出する - 過去実績の LTM 4Q と、予想の NTM 4Q を並べて表示する設計にする
- Turso のマイグレーション → Koyfin取込スクリプト →
valuation.ts生成 → ページ作成 の Phase 1〜4 で進める
計画書を Codex でレビューさせたら、致命的な指摘が2点返ってきた。
- マイグレーション適用がハードコードになっている — 個別のSQLファイル名を直書きしていたので、追加するたびに壊れる構造だった
- NTM EPS を fq0〜3 の「相対インデックス」で取り込み時に計算していた — スナップショットを撮った時点の相対位置で固定してしまうと、四半期がまたいだ瞬間に意味がずれる。取り込み時ではなくクエリ時(ビュー側)で算出すべき
この2点を計画に反映してから承認し、memo に保存した。指摘の8割は瑣末なクソリプなので無視するが、この2つは設計の土台に関わるので拾った。判断するのは自分、レビューを回すのは Codex、という分担。
特に2点目は地味だが効く。スナップショットを撮った日が「3Q FY2026 の直前」なのか「3Q を発表した直後」なのかで、fq0 が指す四半期は変わる。取り込み時の相対インデックスで NTM EPS を固定してしまうと、決算をまたいだ次の日には「もう過ぎた四半期」を未来予想として足し続けることになる。ビュー側で「今より先の4Q」を毎回引き直す方式にして、この罠を塞いだ。
Phase 1〜4 を実装する
順番にマイグレーションから積んだ。Turso に 0002〜0004 を追加して、consensus_estimates に価格・NTM/LTM EPS・四半期内訳のカラムを足し、最新スナップショット1行を返す v_latest_valuation ビューを定義した。NTM EPS の合計はこのビューの中で計算する(取り込み時ではなく)。
Koyfin取込スクリプトには、価格と NTM/LTM EPS の算出、四半期内訳カラムへの書き込みを追加させた。valuation.ts 生成スクリプトはビルド前にビューを読んで TypeScript のデータファイルに吐く構成。実行時には Turso に繋がない(SSG前提)。
散布図ページ scatter.vue は、構造転換済み9銘柄を 横軸 = NTM EPS 成長率(次4Q合計 ÷ 直近4Q実績 − 1)、縦軸 = フォワードPER(直近株価 ÷ NTM EPS) でプロットする。LTM が赤字や NTM 4Q が未充足の銘柄は成長率が定義できないので除外し、除外理由を画面に列挙するようにした。「右下=成長率の割に PER が安いゾーン」が一目で拾えるのが狙い。
なぜ過去実績(LTM)と予想(NTM)を並べて出すかというと、フォワードPER は予想 EPS が分母なので、予想が現実離れしていると簡単に「割安に見える」からだ。MU のように直近4Qで EPS が 1.91 → 12.20 へ跳ねている銘柄は、NTM の予想がさらに上を見ていてもおかしくないが、その妥当性は過去の実績の伸びと並べないと判断できない。だから1行に LTM 4Q と NTM 4Q を両方置いた。
KID解決とデータ取り込み
Koyfin の銘柄は内部 ID(KID)で引く必要がある。これを Chrome DevTools MCP 経由で解決させた。POST /api/v1/bfc/tickers/search に categories:['Equity'] を付けて投げる。16銘柄分を1回の evaluate_script でまとめて解決させ、続けて19銘柄分の estimates + 価格を Koyfin から取得して Turso に格納した。
// 1回の evaluate_script で16銘柄を一括解決(KID → ticker のマップを返す)
const search = async (q) =>
(await fetch('/api/v1/bfc/tickers/search', {
method: 'POST',
body: JSON.stringify({ query: q, categories: ['Equity'] }),
})).json()
MUで検算する
数字が正しいか、MU で答え合わせをした。Koyfin の Earnings Matrix と突き合わせる。
- NTM EPS = 19.29 + 23.04 + 25.70 + 26.35 = 94.38
- LTM EPS = 1.91 + 3.03 + 4.78 + 12.20 = 21.92
Koyfin の表示と完全に一致した。ビュー側で合計を取る設計が正しく効いている。あとから自分でも検算できるように、4四半期の内訳を散布図のデータテーブルに併記した。「LTM 1.91+3.03+4.78+12.20=21.92」のように足し算がそのまま見える形にしておくと、画面を開いた瞬間に数字を疑える。
純粋関数を切り出してテストする
compute_ntm_eps / compute_ltm_eps / 各フォーマッタを utils に切り出した。EPS合計のロジックと表示整形を、副作用(DB・fetch)から完全に分離する。これで Python 側は unittest、TypeScript 側は Vitest でテストでき、合計20件超が pass した。境界ケース(4Q未充足、LTM赤字でゼロ除算回避、null混入)も入れた。
ルーティング衝突を踏む
/beat-monitoring/scatter を開いたら、[ticker].vue に ticker='SCATTER' として吸われていた。静的ルートよりキャッチオールが勝ってしまう。原因を追うと、こちら(自分の)dev server の HMR が取りこぼしていただけで、フルリロードすると静的ルートが正しく優先された。設定の問題ではなかった。
ただ、本番で同じ衝突が起きると怖いので、防御線として [ticker].vue 側に予約名のガードを入れた。
const RESERVED_NAMES = new Set(['scatter'])
if (RESERVED_NAMES.has(rawParam.toLowerCase())) {
await navigateTo(`/beat-monitoring/${rawParam.toLowerCase()}`, { replace: true })
}
予約名が来たら静的ルートへ replace でリダイレクトする。HMRの取りこぼしが真因と分かっていても、防御線は1本引いておく。replace にしたのは、ブラウザの戻るボタンで /beat-monitoring/scatter に戻ったときに無限ループの履歴を作らないため。最初は普通の遷移にしていて、戻る操作で違和感が出たので置き換えた。
文字コードで足をすくわれる
取り込みログの途中で、print が cp932 のエンコードエラーを吐いて一部銘柄のログが化けた。一瞬「取り込みも失敗したか」と焦ったが、Turso を直接確認すると書き込み自体は commit 済みだった。ログの表示が壊れただけで、データは無事。print 側のエンコーディングを直して再発を止めた。ログの化けと処理の失敗を混同しないこと、確認はログではなく実体(DB)を見ること。
今日の学び
- NTM EPS の合計は取り込み時に固定するとずれる。四半期がまたいだ瞬間に相対インデックスの意味が変わるので、クエリ時(ビュー側)で算出する。Codex の指摘で踏みとどまった
- マイグレーション適用をハードコードすると、ファイルを足すたびに壊れる。最初から汎用的に積む構造にしておく
- 数字の検算は「画面に内訳を併記する」のが一番速い。MU の足し算がそのまま見えるので、開いた瞬間に疑える
- cp932 のログ化けは処理失敗ではない。ログを見て焦る前に、Turso の実体を確認する
- 純粋関数(合計・フォーマッタ)を副作用から切り離すと、Python と TypeScript の両方でテストが書ける。20件超を pass させて安心して刷新できた
明日やること
- 除外銘柄(NTM 4Q未充足・LTM赤字)の翌日再取得を仕組み化する
- フォワードPERのスナップショットを日次で蓄積し、PERの推移を時系列で見られるようにする