Pythonでドル円のボラが高い時間帯を調べたら、夜中の取引を全部やめた話

基礎知識・戦略

子供が寝た後の夜23時から翌1時。ここが僕のドル円トレードの時間帯でした。他に時間が取れないので、消去法でそうなっていたのです。

ある日ふと疑問が浮かびました。「この時間帯って、本当にトレードに向いているんだろうか」。生活の都合で決めただけで、相場の側の事情はまったく考えていませんでした。

そこで、1時間刻みで勝率とリスクリワードを計算してみることにしました。値動きの大きさだけでなく、「その時間に取引して、実際に儲かったのか」まで踏み込んで検証した記録です。結論から言うと、僕は夜中の取引をやめました。

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

何を測れば「向いている」と言えるのか

検証を始める前に、何をもって「トレードに向いた時間帯」とするかを決めておく必要があります。ここが曖昧だと、都合のいい数字を探すだけになってしまいます。

僕が測ることにしたのは、次の4つです。

指標何が分かるか望ましい状態
値幅どれだけ動くか大きすぎず小さすぎず
方向の継続性トレンドが続くか50%から離れている
勝率実際に当たるかコストを超える水準
リスクリワード勝ちと負けの大きさの比1.0を超える

重要なのは値幅だけを見ないことです。「動く=チャンス」と考えがちですが、動きが大きいだけでは方向が当たらなければ損失が膨らむだけです。

セッション単位での大きな傾向はセッション別ボラティリティの可視化で扱いました。この記事ではもっと細かく、1時間ごとに分解していきます。

検証を始めるにあたって、自分の中で先に「どういう結果なら納得するか」を決めておきました。これをやらないと、都合のいい数字が出るまで条件をいじり続けることになるからです。

  • 上昇率が55%以上なら、その時間帯に何らかの偏りがあると考える
  • 期間を分けても同じ結果が出ることを条件にする
  • コストを引いてもプラスが残ることを確認する

この3つを先に決めたことで、後の判断がぶれずに済みました。結論を出す基準を、データを見る前に決めておく——地味ですが、自己欺瞞を防ぐ一番確実な方法だと思います。

📘 外部参考Forex Trading Sessions(Investopedia)

データを用意する

pip install yfinance pandas numpy matplotlib
import yfinance as yf
import numpy as np
import pandas as pd


def load_hourly(symbol="JPY=X", period="60d"):
    """1時間足を日本時間で取得する"""
    df = yf.download(symbol, period=period, interval="1h",
                     auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)

    if df.index.tz is None:
        df.index = df.index.tz_localize("UTC")
    df.index = df.index.tz_convert("Asia/Tokyo")

    df = df.dropna(subset=["Open", "High", "Low", "Close"]).copy()
    df["hour"] = df.index.hour
    df["ret"] = df["Close"].pct_change() * 100
    df["range"] = (df["High"] - df["Low"]) / df["Close"] * 100
    return df.dropna()


df = load_hourly()
print(f"期間: {df.index[0]:%m/%d %H:%M} 〜 {df.index[-1]:%m/%d %H:%M}")
print(f"本数: {len(df):,}")

タイムゾーンの変換を忘れないでください。yfinanceはUTCで返すことがあるので、日本時間に直さないと分析が9時間ずれます。「深夜3時が最も静か」といったおかしな結果が出たら、まずここを確認します。

値幅を終値で割って%に直しているのもポイントです。ドル円が150円のときと100円のときでは、同じ1円の値幅でも意味が違います。比率にしておくと、期間をまたいでも比較できます。

時間帯別に集計する

def hourly_stats(df):
    """1時間ごとの統計をまとめる"""
    rows = []
    for h in range(24):
        sub = df[df["hour"] == h]
        if len(sub) < 10:
            continue

        r = sub["ret"]
        wins = r[r > 0]
        losses = r[r < 0]

        rows.append({
            "時刻": h,
            "本数": len(sub),
            "平均値幅%": round(float(sub["range"].mean()), 4),
            "標準偏差": round(float(r.std()), 4),
            "上昇率%": round(float((r > 0).mean()) * 100, 1),
            "平均利益": round(float(wins.mean()), 4) if len(wins) else 0,
            "平均損失": round(float(losses.mean()), 4) if len(losses) else 0,
        })

    out = pd.DataFrame(rows).set_index("時刻")
    # リスクリワード比(勝ちの大きさ ÷ 負けの大きさ)
    out["RR比"] = (out["平均利益"] / out["平均損失"].abs()).round(2)
    return out


stats = hourly_stats(df)
print(stats.to_string())

この表で注目すべきは「上昇率%」の列です。これは「その時間帯に価格が上がった割合」で、50%から大きく離れていれば何らかの偏りがあることを意味します。

実際に計算すると、ほとんどの時間帯で48〜52%に収まります。つまり、どの時間に買っても上がる確率はほぼ半々ということです。

偏りが本物かを検定する

「53%だから上がりやすい」と結論づける前に、それが偶然の範囲かどうかを確かめます。

from scipy.stats import binomtest

print(f"{'時刻':>4s} {'本数':>5s} {'上昇率':>7s} {'p値':>8s}  判定")
for h in range(24):
    sub = df[df["hour"] == h]
    if len(sub) < 20:
        continue

    ups = int((sub["ret"] > 0).sum())
    n = len(sub)
    p = binomtest(ups, n, 0.5).pvalue

    verdict = "偏りあり?" if p < 0.05 else "偶然の範囲"
    print(f"{h:>3d}時 {n:>5d} {ups / n * 100:>6.1f}% {p:>8.3f}  {verdict}")

ここでほとんどの時間帯が「偶然の範囲」になることが分かります。仮に1つ2つがp値0.05を下回っても、24時間分を試している以上、偶然そうなるものが出て当然です。

この「たくさん試せば偶然有意なものが出る」という問題は、p値を知ってバックテストの見方が変わった話で詳しく扱っています。24通り試すなら、必要なp値は0.05ではなく0.002程度まで厳しくすべきです。

自分のトレード時間を検証する

本題です。僕の23時〜1時という時間帯は、他と比べてどうなのかを確かめます。

MY_HOURS = [23, 0, 1]          # 自分のトレード時間帯


def compare_slots(df, my_hours):
    """自分の時間帯と、他の候補を比較する"""
    slots = {
        "自分(23-1時)": my_hours,
        "東京前場(9-11時)": [9, 10, 11],
        "東京後場(12-14時)": [12, 13, 14],
        "欧州序盤(16-18時)": [16, 17, 18],
        "NY序盤(21-23時)": [21, 22, 23],
        "早朝(6-8時)": [6, 7, 8],
    }

    rows = []
    for name, hours in slots.items():
        sub = df[df["hour"].isin(hours)]
        if len(sub) < 20:
            continue

        r = sub["ret"]
        wins, losses = r[r > 0], r[r < 0]
        rr = float(wins.mean() / abs(losses.mean())) if len(losses) else np.nan

        rows.append({
            "時間帯": name,
            "本数": len(sub),
            "値幅%": round(float(sub["range"].mean()), 4),
            "上昇率%": round(float((r > 0).mean()) * 100, 1),
            "RR比": round(rr, 2),
            "期待値": round(float(r.mean()), 4),
        })

    return pd.DataFrame(rows)


print(compare_slots(df, MY_HOURS).to_string(index=False))

この比較で見えてきたのは、「自分の時間帯だけが特別に不利なわけではない」ということでした。上昇率もRR比も、他の時間帯と大差ありません。

唯一はっきり違ったのが値幅です。僕の時間帯は、東京時間の倍近く動いていました。当たる確率は同じなのに、外したときの損失だけが大きい——これが連敗の正体でした。

実際に売買したらどうなるかを試す

統計だけでは実感が湧かないので、単純な戦略を時間帯別に走らせて比較しました。

COST = 0.004     # 往復コスト(スプレッド相当、%)


def simulate_by_hour(df, lookback=5, cost=COST):
    """時間帯別に、単純な順張り戦略の成績を測る

    直近lookback本の方向に従ってエントリーし、1本後に手仕舞う
    """
    d = df.copy()
    d["signal"] = np.sign(d["Close"].diff(lookback))
    d["pnl"] = d["signal"].shift(1) * d["ret"] - cost

    rows = []
    for h in range(24):
        sub = d[d["hour"] == h].dropna(subset=["pnl"])
        if len(sub) < 20:
            continue

        pnl = sub["pnl"]
        rows.append({
            "時刻": h,
            "取引数": len(pnl),
            "勝率%": round(float((pnl > 0).mean()) * 100, 1),
            "平均損益": round(float(pnl.mean()), 4),
            "合計損益": round(float(pnl.sum()), 2),
        })

    return pd.DataFrame(rows).set_index("時刻")


sim = simulate_by_hour(df)
print(sim.to_string())

best = sim["合計損益"].idxmax()
worst = sim["合計損益"].idxmin()
print(f"\n最良の時間帯: {best}時台 ({sim.loc[best, '合計損益']:+.2f})")
print(f"最悪の時間帯: {worst}時台 ({sim.loc[worst, '合計損益']:+.2f})")
print(f"プラスの時間帯: {int((sim['合計損益'] > 0).sum())} / {len(sim)}")

コストを引いているのが重要です。スプレッドを無視すると、どの時間帯も「そこそこ稼げる」ように見えてしまいます。

結果を見ると、プラスの時間帯とマイナスの時間帯が入り混じり、明確な優位性は見つかりません。しかもデータ期間を変えると、最良と最悪の時間帯が入れ替わります。

# 期間を分けて、結果が安定しているか確認する
mid = len(df) // 2
first, second = df.iloc[:mid], df.iloc[mid:]

sim1 = simulate_by_hour(first)
sim2 = simulate_by_hour(second)

comp = pd.DataFrame({
    "前半": sim1["合計損益"],
    "後半": sim2["合計損益"],
}).dropna()
comp["符号一致"] = np.sign(comp["前半"]) == np.sign(comp["後半"])

print(comp.to_string())
print(f"\n符号が一致した時間帯: {int(comp['符号一致'].sum())} / {len(comp)}")
print("→ 半々なら、時間帯による優位性は再現していません")

ここが決定的でした。前半で儲かった時間帯が、後半では損している——つまり「この時間帯が有利」という結論は、単に過去の偶然を拾っていただけだったのです。

それでも夜中の取引をやめた理由

「どの時間帯にも優位性はない」なら、夜中に取引しても問題ないはずです。それでも僕がやめたのには、データ以外の理由がありました。

理由1:値幅が大きいぶん、判断ミスの代償が重い

勝率が同じなら、値幅が大きい時間帯ほど1回の失敗が痛いということです。損益の振れ幅が大きくなるだけで、期待値は改善しません。

# 同じロットで取引した場合の損益の振れ幅を比べる
for name, hours in [("東京(10-14時)", [10, 11, 12, 13, 14]),
                    ("深夜(23-1時)", [23, 0, 1])]:
    sub = df[df["hour"].isin(hours)]
    r = sub["ret"]
    print(f"{name}")
    print(f"  平均変動率  : {float(r.abs().mean()):>7.4f} %")
    print(f"  標準偏差    : {float(r.std()):>7.4f} %")
    print(f"  最悪の1本   : {float(r.min()):>7.4f} %")
    print(f"  上位5%の損失: {float(r.quantile(0.05)):>7.4f} %")

深夜帯は「上位5%の損失」が明確に大きくなります。滅多に起きないはずの損失が、想定より重くのしかかるということです。

理由2:指標発表が集中している

米国の重要指標は日本時間の21時30分と23時ごろに集中します。まさに僕がトレードしていた時間帯です。

# 極端な動きがどの時間帯に集中しているかを調べる
threshold = df["ret"].abs().quantile(0.98)
extreme = df[df["ret"].abs() > threshold]

dist = extreme["hour"].value_counts().sort_index()
print("極端な値動き(上位2%)が起きた時刻")
for h, n in dist.items():
    bar = "█" * n
    print(f"  {h:>2d}時: {n:>3d} {bar}")

実行すると、21〜23時台に極端な動きが集中しているのがはっきり見えます。テクニカル指標での判断が最も通用しにくい時間帯だということです。

理由3:自分のコンディションが悪い

これが決め手でした。相場のデータには表れませんが、深夜の自分は明らかに判断力が落ちています。

市場の側に優位性がなく、しかも値幅が大きく、指標発表が集中し、自分の状態も悪い。3つ揃っているなら、あえてその時間に張る理由はありません。

観点深夜(23-1時)東京(10-14時)
方向の当てやすさ約50%約50%(同じ)
値幅約2倍小さい
指標発表集中ほぼない
自分の判断力低い高い

どう運用を変えたか

時間帯を変えられないなら、やり方のほうを変えるしかありません。実際に取った対策は3つです。

その場で判断せず、予約注文にする

いちばん効果があったのがこれです。判断は判断力のある時間帯に行い、深夜は執行だけを任せるという分離です。

def build_order_plan(df, entry_hour=22):
    """日中に条件を決めておき、夜は自動執行するための計画を作る"""
    latest = df.iloc[-1]
    price = float(latest["Close"])

    # その時間帯の平均値幅から損切り・利確を決める
    typical = float(df[df["hour"] == entry_hour]["range"].mean())
    stop_pct = typical * 1.5
    take_pct = typical * 2.5

    return {
        "現在値": round(price, 3),
        "想定値幅%": round(typical, 4),
        "買い損切り": round(price * (1 - stop_pct / 100), 3),
        "買い利確": round(price * (1 + take_pct / 100), 3),
        "リスクリワード": round(take_pct / stop_pct, 2),
    }


plan = build_order_plan(df)
for k, v in plan.items():
    print(f"  {k:<14s}: {v}")

損切りと利確をその時間帯の平均値幅から決めているのがポイントです。東京時間の感覚で損切りを置くと、深夜帯ではノイズで刈られます。

ロットを時間帯に合わせる

def lot_multiplier(df, hour, base_hours=(10, 11, 12, 13, 14)):
    """基準時間帯と同じリスクになるロット倍率を返す"""
    base = float(df[df["hour"].isin(base_hours)]["ret"].std())
    target = float(df[df["hour"] == hour]["ret"].std())
    return round(base / target, 2) if target > 0 else 1.0


print(f"{'時刻':>4s} {'標準偏差':>9s} {'ロット倍率':>11s}")
for h in (10, 16, 21, 23, 0, 1, 6):
    sd = float(df[df["hour"] == h]["ret"].std())
    print(f"{h:>3d}時 {sd:>9.4f} {lot_multiplier(df, h):>11.2f}")

これで「深夜は東京時間の半分のロット」という調整に根拠が持てます。感覚で決めるより、はるかに納得して実行できます。

ポジションサイズの考え方は標準偏差を使ってポジションサイズを決める方法で体系的にまとめています。

指標発表の前後は手を出さない

単純ですが効きます。21時30分と23時の前後30分は取引しないと決めるだけで、想定外の損失がかなり減りました。

テクニカル指標は「過去の値動きの延長」を前提にしています。指標発表は、その前提が壊れる瞬間です。分析の対象外だと割り切るのが合理的です。

まとめ

ドル円を1時間刻みで分析し、自分のトレード時間帯を見直した記録をまとめました。

  • 判断材料は値幅・継続性・勝率・リスクリワードの4つ。値幅だけを見ない
  • どの時間帯も上昇率は48〜52%。方向の当てやすさに差はない
  • 24通り試せば偶然「有意」なものが出る。期間を分けて再現性を確認する
  • 前半と後半で最良の時間帯が入れ替わるなら、それは偶然を拾っただけ
  • 深夜帯は極端な値動きが集中し、指標発表とも重なる
  • 対策は予約注文への切り替え・ロット調整・指標前後の回避
  • 損切り幅はその時間帯の平均値幅から決める。日中の感覚は通用しない

この検証で一番の収穫は、「有利な時間帯を見つける」という発想そのものを捨てられたことでした。探せば何かしら見つかりますが、それは過去の偶然でしかありません。

代わりに得たのは、「どの時間帯も条件は同じ。違うのはリスクの大きさと自分の状態だけ」という理解です。だとすれば、やるべきことは有利な時間を探すことではなく、自分が置かれた条件に合わせて張り方を変えることになります。

まずは自分がいつも取引している時間帯で、標準偏差と「上位5%の損失」を計算してみてください。他の時間帯と比べたときの差が、そのままロット調整の根拠になります。

タイトルとURLをコピーしました