この記事でやること
Google Search Console(GSC)のデータを使ったCTR(クリック率)の分析と、株のRSI(売られすぎ・買われすぎを示す指標)を使った売買判定。まったく別の仕事に見えますが、実装してみると同じ4段のパイプラインになりました。
- 取得:データを取ってくる
- 判定:基準と比べて、ズレているものを拾う
- 記録:判断した時点の値を台帳に固定する
- 再計測:決めた日に、同じ指標で測り直す
この記事では、4段それぞれの実装と、両方で踏んだ落とし穴をまとめます。トレード側は毎営業日16時に自動で動かしており、2026年11月中旬に検証の判定日を設けています。
この記事は投資の勧誘を目的としたものではありません。個別銘柄は記号(銘柄Aなど)で表記し、株価の金額は伏せています。
4段の対応表
| 段 | CTR分析(SEO) | 売買判定(トレード) |
|---|---|---|
| 取得 | GSC の検索アナリティクス(ページ×クエリ) | J-Quants・yfinance の日足 |
| 判定 | 順位ごとの期待CTRを下回るページ | RSIが35を下回った銘柄 |
| 記録 | 変更前のタイトル・メタ説明と変更日 | 判断日・判断時のRSI・約定価格 |
| 再計測 | 変更から3週間後 | 決めた判定日 |
1. 取得:「取れたつもり」を疑う
取得の段で一番怖いのは、エラーにならずに、少ない・古い・間違ったデータが返ってくることです。両方でこれを踏みました。
GSC:クエリ別の合計はページの実数と一致しない
GSC でページ×クエリの組み合わせを取ると、プライバシー保護のために表示回数の少ないクエリが匿名化され、結果から落ちます。そのためクエリ別に足した合計は、ページ単位で取った実数より大きく少なく出ます。
ページの表示回数・クリック数を判断に使うときは、クエリ次元を付けずにページで絞った値を正とし、クエリ別の値は「どの語で見られているか」の内訳としてだけ使います。
yfinance:配当調整と、桁違いの寄付
株価側では次の2つを踏みました。
-
配当調整の有無:
auto_adjust=Falseで取って自前で計算すると、配当落ちの日にRSIが数ポイントずれる。報告とダッシュボードでは同じ取得関数・同じ計算関数を必ず通す - 寄付(始値)が桁違いで返る:終値に対して寄付が約50分の1になっている日足が混ざることがある
日本株には値幅制限があるので、寄付と終値が2倍以上離れることは通常ありえません。そういう日足は誤値として捨てます。
import pandas as pd
# 寄付と終値が2倍以上ずれる日足は値幅制限上ありえない=誤値として捨てる
MAX_OPEN_CLOSE_RATIO = 2.0
def drop_broken_bars(bars: pd.DataFrame) -> pd.DataFrame:
ratio = bars["Open"] / bars["Close"]
valid = (ratio < MAX_OPEN_CLOSE_RATIO) & (ratio > 1 / MAX_OPEN_CLOSE_RATIO)
return bars[valid]
もうひとつ、取得した銘柄数が母集団の半分に満たない日は、取得制限に当たったとみなしてその日は記録しないようにしています。空のデータで「今日は該当なし」と記録するより、記録しないほうが安全です。
2. 判定:「基準と比べてズレを返す」を同じ形で書く
判定の段は、両方とも絶対値ではなく、条件ごとの基準と比べる形になります。CTRが3%と聞いても、1位なら低すぎ、10位なら十分です。RSIも数値そのものではなく、35を割ったかどうかで判断します。
CTR:順位ごとの期待値と比べる
# 順位帯ごとの「これを下回ったら対象」の目安(AI Overview 等で実際のCTRは下がる傾向。相対判断に使う)
CTR_TRIGGERS = [
(2, 0.12), # 1〜2位
(4, 0.04), # 3〜4位
(6, 0.025), # 5〜6位
(10, 0.015), # 7〜10位
(15, 0.008), # 11〜15位
(20, 0.004), # 16〜20位
]
MIN_IMPRESSIONS = 50
def ctr_trigger(position: float) -> float | None:
for upper, trigger in CTR_TRIGGERS:
if position <= upper:
return trigger
return None # 20位より下はCTRではなく順位の問題として扱う
def low_ctr_pages(rows: list[dict]) -> list[dict]:
"""表示は多いのに、順位に対してクリックされていないページを返す"""
picked = []
for row in rows:
trigger = ctr_trigger(row["position"])
if trigger is None or row["impressions"] < MIN_IMPRESSIONS:
continue
if row["ctr"] < trigger:
picked = [*picked, {**row, "gap": trigger - row["ctr"]}]
return sorted(picked, key=lambda r: r["gap"], reverse=True)
RSI:35を割ったものを拾う
RSIはワイルダーの平滑化(指数移動平均の一種)で計算します。
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
delta = close.diff()
gain = delta.clip(lower=0).ewm(alpha=1 / period, adjust=False).mean()
loss = (-delta.clip(upper=0)).ewm(alpha=1 / period, adjust=False).mean()
return 100 - 100 / (1 + gain / loss)
BUY_BELOW = 35
SELL_ABOVE = 65
def oversold(latest_rsi: dict[str, float]) -> list[tuple[str, float]]:
"""RSIが買いの基準を下回った銘柄を、低い順に返す"""
picked = [(code, value) for code, value in latest_rsi.items() if value < BUY_BELOW]
return sorted(picked, key=lambda item: item[1])
2つを並べると、**「基準を引く → 基準との差を出す → 差の大きい順に並べる」**という同じ骨格になっています。違うのは基準の中身だけです。
判定の前に「動く理由」があるかを見る
CTR側では、判定に通ったページでもすぐに手を入れません。よく検索されている語がタイトルに入っているかを先に確かめます。
import unicodedata
def missing_terms(query: str, title: str) -> list[str]:
"""クエリの語のうち、タイトルに含まれていないものを返す(全角・半角、大文字・小文字をそろえて比べる)"""
normalized_title = unicodedata.normalize("NFKC", title).lower()
terms = unicodedata.normalize("NFKC", query).lower().split()
return [term for term in terms if term not in normalized_title]
語が欠けていればタイトルを直す理由があります。語がすでに入っているのにクリックされない場合は、検索結果の画面側(AI Overview や大手サイト)の問題なので、文言を磨いても動きにくいと判断します。トレードでいえば、条件に届いていない銘柄には入らないのと同じです。
3. 記録:判断した時点の値を固定する
記録の段では、後から値が書き換わらないように、判断した時点の値を台帳に固定することが共通の要点です。
| 固定するもの | 固定しないと困ること | |
|---|---|---|
| SEO | 変更前のタイトル・メタ説明・変更日 | SEOのメタ情報は編集履歴に残らないことがあり、効果が出なかったときに元に戻せない |
| トレード | 約定価格(翌営業日の寄付)・判断日のRSI | 配当落ちで調整済み株価が書き換わり、後から計算すると買値がずれる |
トレード側では、約定価格が分かった時点で台帳の entry_price に書き込み、以後は株価データから計算し直しません。
もうひとつ大事なのが、同じ日に2回実行しても二重に記録しないことです。手で再実行したり、自動実行が重なったりしても台帳が壊れないように、記録の前に「その日付の分がすでにあるか」を確かめます。休場日に実行した場合は、前営業日の結果を表示し直すだけにしています。
4. 再計測:決めた日に、決めた比べ方で
再計測の段で避けたいのは、結果を見てから比べ方を決めることです。そこでトレード側では、判定日より前に比べ方を固定しました。
- 判定日は2026年11月中旬
- ルールを1つずつ変えた8本のペーパートレード(仮想売買)の台帳を、対照群の「①基本」と比べる
- 比べる範囲は、全台帳が同じ条件で買えるようになった2026年9月25日以降に買いを決めた分だけ
- 指標は1件あたりの騰落率(判定日に保有中のものは、その日の終値で評価)
- 台帳全体の損益の合計では比べない(台帳ごとに始めた時期が違い、起点の条件がそろわないため)
SEO側は「変更から3週間後に同じクエリで測る」と決めていますが、トレード側と比べると対照群がありません。施策をしない似た記事を一緒に計測しておかないと、サイト全体の増減と施策の効果を区別できない点は、今後の課題です。
定時実行:launchd で取得が黙って欠ける
トレード側は macOS の launchd で毎営業日16時に実行しています。ここで、ターミナルからは正常に動くのに、自動実行だと取得が黙って欠けるという問題が起きました。
原因は、launchd から起動したプロセスの同時に開けるファイル数の上限が256しかないことでした(ターミナルでは桁違いに大きい)。yfinance で約1,600銘柄を並列に取得すると上限を超え、キャッシュの SQLite が開けずに一部の銘柄が空になります。エラーで止まらないので、気づきにくいのが厄介な点です。
起動直後に上限を引き上げて回避しました。
import resource
OPEN_FILE_LIMIT = 4096
def raise_open_file_limit(target: int = OPEN_FILE_LIMIT) -> int:
"""ソフト上限を target まで(ハード上限を超えない範囲で)引き上げ、実行後のソフト上限を返す"""
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
wanted = target if hard == resource.RLIM_INFINITY else min(target, hard)
if soft >= wanted:
return soft
resource.setrlimit(resource.RLIMIT_NOFILE, (wanted, hard))
return wanted
launchd で大量の取得を行うジョブを新しく作るときは、同じ呼び出しを最初に入れるようにしています。
AIの判断を台帳に入れるときの制限
8本の台帳のうち1本は、Claude Code を claude -p で毎日1回呼び出し、保有銘柄や最近の開示を読ませて「買わない」「売る」の判断を上乗せしています。AIに書き込みを許すので、次のように範囲を絞りました。
- 許すのは読むことと、判断用のCSV 1ファイルの編集だけ
- 実行後に、判断ファイルの形式と、他の git 管理ファイルが変わっていないかを確かめる。崩れていれば元に戻して通知する
- 指示文には「具体的な出来事や開示があるときだけ上書きする。迷ったら何もしない」と書く
さらに、AIの判断を入れない影の台帳を並走させ、その差だけを「AIの判断の価値」として測ります。AIを入れた台帳だけを見ても、AIが効いたのか、もとのルールが効いたのかが区別できないためです。
まとめ
- CTR分析と売買判定は、取得 → 判定 → 記録 → 再計測という同じ4段のパイプラインになる
- 取得の段では、エラーにならない欠損や誤値を疑う(GSCの匿名化・yfinanceの誤値・launchdのファイル数上限)
- 判定の段は、条件ごとの基準と比べてズレを返す同じ骨格で書ける
- 記録の段では、判断した時点の値を固定し、二重記録を防ぐ
- 再計測の段では、比べ方を判定日より前に決めておく
数字を絶対値ではなく条件ごとの基準と比べる考え方は、アクセス解析のほかの指標にもそのまま使えます。広告の直帰率を例にした整理は「ディスプレイ広告の直帰率80%は正常なのか|平均56.5%が使えない理由」で解説しています。
SEOスコアチェックツール: Direbase(ディレベース) — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work