Insights·2026-07-31

YouTubeの投資主張をバックテストで検証するには — 字幕から無料の株価APIまで

YouTubeの動画が「今が底だ」と言うとき、信じるか信じないかのほかに三つ目の道がある。主張を数値のルールに置き換えて過去のデータで走らせることで、これをバックテストという。必要な道具は二つだけだ — 動画の字幕をテキストとして取り出すyt-dlpと、登録も課金もなしに日次終値をくれる公開API(米国株はナスダック、韓国株はNaver金融)。手順は三つ。字幕を取得して主張を文章として確保し、「急落して反発したから底だ」という人の言葉を「直近60営業日の高値から35%以上下げた状態で、当日終値が前日比20%以上上昇したら、その日の終値で買う」という条件と数字に置き換え、そのルールを過去10年のデータで走らせる。結果を読むときに必須なのは二つ — 同じ銘柄をただ持ち続けていた場合(比較群)と、平均ではなく中央値だ。比較群がなければ上昇相場ではどんなルールも良く見えるし、平均だけを見れば少数の大当たりが作った数字をルールの実力と取り違える。そして数値条件にどうしても置き換えられない主張が出てきたら、それは検証の失敗ではなく最初から検証できない主張であり、その判定自体が結果である。

バックテストとは何か、なぜ必要か

投資系の動画を見ていると、いつも同じところで止まる。「今が底です」と言われた瞬間だ。根拠はもっともらしく、画面のグラフにも説得力があるのに、これが正しいのか確かめる方法がないので、結局は信じるか信じないかのどちらかで終わる。

バックテストはここに三つ目の道を開く。ある売買判断をルールに置き換え、そのルールをそのまま過去のデータで走らせて、実際に儲かったのかを数える方法だ。未来を当てる道具ではなく、いま聞いている主張が過去には通用したのかを確かめる道具である。

専門ソフトや有料データが要ると思いがちだが、そんなことはない。字幕を取り出す無料ツールが一つと、株価を公開でくれるAPIが二つあれば半日で終わる。以下は実際に一本の動画を検証したときの手順そのままだ。

手順1 — yt-dlpで動画の字幕をテキストにする

まず内容を文章として確保する。17分の動画を何度も巻き戻して見る代わりに、字幕をテキストファイルとして取っておけば、どこにどの主張があったかを検索で探せる。

yt-dlpはこれを行う無料のコマンドラインツールだ。名前はyoutube-dlの後継版という意味で、YouTubeを含む非常に多くのサイトから動画と字幕を取得できる。Macならターミナルでbrew install yt-dlp、Python環境があればpip install yt-dlpでインストールする。

インストールが済んだら、動画は取らずに字幕だけを落とす。--skip-downloadが動画ファイルを飛ばす指定で、--write-auto-subが人手の字幕がないときにYouTubeの自動生成字幕でも取る指定だ。落ちてくるのはvtt形式の字幕ファイルで、タイムスタンプと重複行が混ざっているため、テキストだけを残す整形が一度必要になる。

整形まで済ませると、17分の動画が1万字前後の文章になる。この時点から動画を再び開く必要はない。

ターミナル — 字幕だけをダウンロード
# インストール(どちらか)
brew install yt-dlp        # macOS
pip install yt-dlp         # Python 環境

# 動画は飛ばして韓国語・英語の字幕だけ
yt-dlp --skip-download \
       --write-auto-sub --write-sub \
       --sub-lang "ko,en" --sub-format vtt \
       -o "vid.%(ext)s" "<動画のURL>"

# 結果: vid.ko.vtt
clean.py — vttからテキストだけを残す
import re

lines, prev = [], None
for line in open("vid.ko.vtt", encoding="utf-8"):
    # タイムスタンプ行とヘッダーは捨てる
    if "-->" in line or line.startswith(("WEBVTT", "Kind:", "Language:")):
        continue
    text = re.sub(r"<[^>]+>", "", line).strip()   # タグ除去
    if text and text != prev:                      # 連続する重複を除去
        lines.append(text)
        prev = text

open("transcript.txt", "w", encoding="utf-8").write("\n".join(lines))
print(len(lines), "行")

手順2 — 主張を数値のルールに置き換える

ここが検証のすべてだ。字幕から核心の主張を見つけ、人の言葉で書かれた文をコンピュータが判定できる条件に変える。

たとえば「大きく下げてから大きく反発したので底を確認した」という言葉は、そのままでは検証できない。どれだけ下げれば大きく下げたことになるのか、何日で何%上がれば大きな反発なのか、それで結局いつ買うのかが書かれていないからだ。これを「直近60営業日の高値から35%以上下落した状態で、当日終値が前日比20%以上上昇したら、その日の終値で買う」と置き換えて初めて、コンピュータが過去データから該当する日をすべて拾える。

置き換えるときに守るべき原則が一つある。買う時点で実際に分かる情報だけを条件に入れることだ。「底を打ってから」のように後になってしか分からない表現が入ると、未来を先に知っているルールになり、結果は必ず良く出る。これを先読みバイアスといい、初心者が作るバックテストが実際には通用しない最も多い理由である。

そしてこの段階で半分はふるい落とされる。数値条件にどうしても置き換えられない主張があるからだ。「誰かが意図的に潰したのだ」といった文は、真偽を判定する条件そのものを作れない。これは検証に失敗したのではなく、最初から検証できない主張だったと分かったということであり、その判定自体が結果だ。検証できる主張とできない主張を分けるだけでも、動画一本の信頼度はかなり整理される。

手順3 — 無料の公開APIで過去の株価を取得する

意外にもこの段階が一番簡単だ。有料データを買う必要はない。米国株はナスダックが、韓国株はNaver金融が、日次の株価を公開URLでそのままくれる。登録もAPIキーも課金も要らない。

ナスダックはapi.nasdaq.com配下のhistoricalパスに銘柄コードと期間を付けると、日付・始値・終値・出来高が入ったJSONを返す。ただし応答の価格がドル記号とカンマ付きの文字列なので数値に直す処理が一行必要で、取得できる期間はおおむね直近10年までだ。

韓国株はNaver金融の株価URLに6桁の銘柄コードと開始・終了日を入れる。サムスン電子なら005930、SKハイニックスなら000660だ。応答が正式なJSONではなくシングルクォート混じりの配列形式なので、そのままではパースできない。クォートを置き換えるか正規表現で値を抜き出す。

どちらも人がブラウザで使う前提で作られているため、プログラムから呼ぶときは通常のブラウザに見えるリクエストヘッダー(User-Agent)を付けるほうが安全だ。短時間に呼びすぎると一時的に弾かれることもあるので、銘柄ごとに0.5秒ほど間を置いて取得し、取得したデータはファイルに保存して再利用する。

fetch_us.py — ナスダックの日次終値
import json, urllib.request

UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                    "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"}

def daily(symbol, asset="stocks", frm="2016-01-01", to="2026-07-31"):
    url = (f"https://api.nasdaq.com/api/quote/{symbol}/historical"
           f"?assetclass={asset}&fromdate={frm}&todate={to}&limit=99999")
    req = urllib.request.Request(url, headers=UA)
    rows = json.load(urllib.request.urlopen(req, timeout=30))
    rows = rows["data"]["tradesTable"]["rows"]

    out = []
    for r in rows:
        close = float(r["close"].replace("$", "").replace(",", ""))   # "$207.12" → 207.12
        mm, dd, yy = r["date"].split("/")                              # "07/30/2026"
        out.append({"d": f"{yy}-{mm}-{dd}", "c": close})
    out.sort(key=lambda x: x["d"])
    return out

bars = daily("MU")
print(len(bars), bars[-1])
fetch_kr.py — Naver金融の日次終値
import re, urllib.request

UA = {"User-Agent": "Mozilla/5.0"}

def daily_kr(code, start="20160101", end="20260731"):
    url = ("https://api.finance.naver.com/siseJson.naver"
           f"?symbol={code}&requestType=1&startTime={start}&endTime={end}&timeframe=day")
    req = urllib.request.Request(url, headers=UA)
    text = urllib.request.urlopen(req, timeout=30).read().decode("utf-8")

    # 正式なJSONではなくシングルクォート配列なので正規表現で抜く
    # ['20260730', 始値, 高値, 安値, 終値, 出来高, 外国人保有比率]
    rows = re.findall(r"\['(\d{8})',\s*[\d.]+,\s*[\d.]+,\s*[\d.]+,\s*([\d.]+)", text)
    out = [{"d": f"{d[:4]}-{d[4:6]}-{d[6:]}", "c": float(c)} for d, c in rows]
    out.sort(key=lambda x: x["d"])
    return out

print(daily_kr("005930")[-1])   # サムスン電子

手順4 — ルールを走らせ、必ず比較群と並べて見る

データが揃ったら、手順2で作った条件に合う日をすべて拾い、その後1週間・1か月・3か月後の収益率を計算する。ここまでは機械的な作業だ。

肝心なのはその次である。必ず比較群も一緒に計算する。同じ銘柄をシグナルと無関係にただ持ち続けていたら同じ期間にいくらになったか、指数(たとえばS&P 500)はどれだけ上がったかを並べて置く。これを抜かすと、上昇相場ではどんなルールも良く見える。どの日に買っても儲かった区間だからだ。

実際にAI関連銘柄のバスケットでやってみると、急落後の買いシグナルの3か月収益率は平均12.6%で悪くなさそうに見える。ところが同じ銘柄をどの日に買っても3か月持った場合の平均が12.6%だ。つまりシグナルが生んだ分はほぼゼロである。比較群を計算していなければ、このルールは見事な発見のように見えたはずだ。

サンプル数も併記する。条件を厳しくするほどシグナルは減るが、シグナルが3件や4件しかないルールの平均収益率は統計ではなく偶然だ。条件を複数の組み合わせに変えて走らせ(パラメータスイープ)、良く見える組み合わせがすべてサンプル3〜4件なら、それは発見ではなく過剰最適化である。

backtest.py — シグナルの成績を比較群と並べる
import statistics as st

def backtest(bars, dd_thresh=-0.35, pop=0.20, horizon=63):
    """60日高値からdd_thresh以下の状態で、1日にpop以上上昇 → その日の終値で買う"""
    signals, baseline = [], []

    for i in range(60, len(bars) - horizon):
        c, prev = bars[i]["c"], bars[i - 1]["c"]
        high60 = max(b["c"] for b in bars[i - 60:i + 1])
        fwd = bars[i + horizon]["c"] / c - 1        # horizon日後の収益率

        baseline.append(fwd * 100)                   # 比較群: すべての営業日
        if c / prev - 1 >= pop and c / high60 - 1 <= dd_thresh:
            signals.append(fwd * 100)                # シグナルが出た日だけ

    return signals, baseline

sig, base = backtest(bars)
print(f"シグナル n={len(sig):4d}  平均 {st.mean(sig):6.1f}%  中央値 {st.median(sig):6.1f}%")
print(f"比較群   n={len(base):4d}  平均 {st.mean(base):6.1f}%  中央値 {st.median(base):6.1f}%")
# シグナルの平均が比較群を超えないなら、そのルールに実力はない

結果の読み方 — 平均だけを見ると逆に読む

数字が出ると平均から見たくなるが、バックテストで平均だけを見るのはほぼ必ず誤読につながる。平均と中央値を並べて置くことだ。

はっきりした実例がある。「60日高値から35%以上下げた状態で1日に20%以上反発」という条件を10年分のデータで走らせると24件のシグナルが拾える。この24件の3か月後の収益率は平均38.2%だ。見事に見える。ところが中央値は-1.0%で、利益が出たのは24件中11件と半分に満たない。

何が起きたかというと、数件の大当たり(うち一件は3か月で258%)が平均を独りで引き上げたのだ。残りの半分はその場からさらに下げている。平均だけを見ていれば「急落後の反発は強力な買いシグナル」と結論したはずで、中央値を一緒に見たからこそ「期待値が少数の大当たりに乗った勝率46%の賭け」という正反対の結論になる。

もう一つ気をつけるのが生存者バイアスだ。今日好調な銘柄でバスケットを作って過去10年を走らせれば当然良い結果が出る。その10年で消えた銘柄はリストに載っていないからだ。上場して1年しか経っていない銘柄が混じっていれば、それ以前の期間には存在すらしなかった銘柄で収益率を計算することになる。各時点で実際に存在していた銘柄だけを含めるように処理し、データ不足で除外した銘柄があれば結果に併記する。

つまずきやすいところ

株価APIが急に429やToo Many Requestsを返したら、短時間に呼びすぎている。銘柄ごとに0.5秒ずつ間を置いて取得し、取得したデータは必ずファイルに保存して再実行時に呼び直さないようにする。

字幕がまったく取れない動画もある。人手の字幕がなく自動字幕も切られている場合で、そのときは音声認識に起こす別の工程が要る。ただし自動字幕は数字や固有名詞をよく間違えるので、字幕から取った数値をそのまま事実として使わず、元の出典で確認し直す。

米国株のティッカーは会社が合併したり名前が変わったりすると消える。過去の事例を検証してデータが0件で返ってきたら、まず銘柄が無くなっていないかを確認する。こうした銘柄を黙って外して計算すると、結果は実際より良く出る。

最後に、期間が足りないシグナルを平均に入れないよう注意する。3か月の成績を見ているのにシグナルが2週間前に出たのなら、その件はまだ結果が無い。これを無理に入れると直近の相場が結果全体を汚染する。結果の無い件はサンプルから外し、何件外したかを書く。