FX / EA Python 個人開発

Pythonを用いてFXのバックテストを行う3ステップ|データ取得・実行・改善まで解説【サンプルコードあり】

本ページにはプロモーションが含まれています。

FXの売買ルールを作ったとき、「過去の相場でも利益が出ていたのか」を確認する方法の1つがバックテストです。

過去のローソク足データを使って検証すれば、実際に資金を使う前に、取引回数や勝率、合計損益などを確認できます。

しかし、初めてPythonでバックテストを行う場合は、

  • ローソク足データをどのように読み込むのか
  • 売買ルールや利益確定・損切りをどこで設定するのか
  • Pythonスクリプトをどのように実行するのか
  • バックテスト結果をどのように確認するのか

といった疑問を持つ方も多いのではないでしょうか。

そこで本記事では、CSVに保存したローソク足データとPythonスクリプトを使って、FXの売買ルールをバックテストする方法を紹介します。

今回のスクリプトでは、15分足のSMAによるエントリー条件と、1時間足・4時間足・日足のトレンド条件を組み合わせて、ロング取引を検証します。

そのまま利用できるPythonのサンプルコードとコンフィグも掲載しているため、手順に沿って進めることでバックテストの実行が可能です。

また、設定を変更した場合の結果も比較し、勝率や合計損益、PF、最大ドローダウンがどのように変化するのかも紹介します。

ぜひ最後までご覧ください!

サンプルコードだけ確認したい方は、以下をクリックしてください。

↓ ↓ ↓ クリックすると開けます ↓ ↓ ↓

backtest_fx.py(Pythonスクリプト)
import bisect
import csv
from collections import deque
from datetime import datetime, timedelta

import backtest_config as config

# 日足・4時間足・1時間足が上昇トレンドのとき、15分足のゴールデンクロスで買い、
# 30pipsの損切りまたは40pipsの利益確定で決済するロング専用EAを検証します。

# 使い方:
# 1. backtest_config.py の設定値を必要に応じて変更します。
# 2. GMO-API フォルダで次のコマンドを実行します。
#    python backtest_fx.py
# 3. 結果は data/backtests/USD_JPY/YYYYMMDD_HHMMSS フォルダへ保存されます。
#
# 上の import backtest_config as config で設定ファイルを読み込んでいます。
# このサンプルはロング(買い)だけをバックテストします。
# ASK価格だけを使う基礎サンプルのため、スプレッドなどの取引コストは考慮しません。

# -----------------------------------------------------------------------------
# バックテストで使用する基本情報
# -----------------------------------------------------------------------------

# 各時間足のローソク足1本が、何分または何時間なのかを設定します。
# CSVのtimestamp_jstはローソク足の開始時刻なので、この時間を足すことで
# ローソク足が確定した時刻を求めます。
INTERVAL_DURATIONS = {
    "15min": timedelta(minutes=15),
    "1hour": timedelta(hours=1),
    "4hour": timedelta(hours=4),
    "1day": timedelta(days=1),
}

# trades.csvへ出力する日本語の列名と、その並び順です。
TRADE_COLUMNS = [
    "売買方向", "エントリー日時", "エントリー価格", "決済日時", "決済価格",
    "決済理由", "保有本数", "損益(pips)", "損益(円)",
]


def load_candles(interval, csv_path):
    """fetch_gmo_candles.py形式のCSVを読み込む。"""

    # 指定したCSVがない場合は、対象の時間足を示して処理を止めます。
    if not csv_path.exists():
        raise FileNotFoundError(
            f"CSVが見つかりません: {csv_path}\n"
            f"fetch_gmo_candles.pyで {interval} のデータを取得してください。"
        )

    # CSVの各行を、バックテストで扱いやすい辞書へ変換して格納します。
    candles = []
    with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file)

        # 必要な5列がCSVにそろっているか確認します。
        required_columns = {"timestamp_jst", "open", "high", "low", "close"}
        if not required_columns.issubset(reader.fieldnames or []):
            raise ValueError(f"CSVの列が不足しています: {csv_path}")

        for row in reader:
            # 日時はdatetime、価格はfloatへ変換します。
            # 文字列のままでは日時の比較や価格の計算がしにくいためです。
            candles.append(
                {
                    "open_time": datetime.fromisoformat(row["timestamp_jst"]),
                    "open": float(row["open"]),
                    "high": float(row["high"]),
                    "low": float(row["low"]),
                    "close": float(row["close"]),
                }
            )

    # CSVの並び順に関係なく、古いローソク足から順番に並べます。
    candles.sort(key=lambda candle: candle["open_time"])
    for candle in candles:
        # 未完成の上位足を参照しないよう、利用可能になる時刻を記録します。
        candle["close_time"] = candle["open_time"] + INTERVAL_DURATIONS[interval]
    return candles


def add_sma(candles, period, column_name):
    """各ローソク足へ単純移動平均線を追加する。"""

    # windowには、SMAの計算に必要な直近の終値だけを残します。
    window = deque()
    window_sum = 0.0
    for candle in candles:
        # 新しい終値を計算対象へ加えます。
        window.append(candle["close"])
        window_sum += candle["close"]

        # 指定期間より古い終値は計算対象から外します。
        if len(window) > period:
            window_sum -= window.popleft()

        # 必要な本数がそろうまではSMAを計算できないためNoneを入れます。
        candle[column_name] = window_sum / period if len(window) == period else None


def make_completed_trend_lookup(candles, sma_column):
    """指定時刻までに確定した上位足のトレンドを検索できる形にする。"""

    # SMAを計算できたローソク足だけをトレンド判定に使います。
    usable = [candle for candle in candles if candle[sma_column] is not None]
    close_times = [candle["close_time"] for candle in usable]

    # 終値がSMAより上なら1、下なら-1、同じなら0です。
    trends = [
        1 if candle["close"] > candle[sma_column]
        else -1 if candle["close"] < candle[sma_column]
        else 0
        for candle in usable
    ]
    return close_times, trends


def trend_at(lookup, signal_time):
    """signal_timeまでに確定済みの最新トレンドを返す。"""

    close_times, trends = lookup

    # bisectを使い、シグナル時刻以前に確定した最新の上位足を探します。
    # 形成途中の上位足を使わないことで、未来の情報を参照するのを防ぎます。
    index = bisect.bisect_right(close_times, signal_time) - 1
    return trends[index] if index >= 0 else None


def has_long_entry_signal(index, candles, trend_lookups):
    """確定した15分足と上位足から、買いシグナルの有無を返す。"""

    # 最初の1本は直前のSMAと比較できないため、エントリーしません。
    if index == 0:
        return False

    # 直前の15分足と、現在の15分足を取り出します。
    previous = candles[index - 1]
    current = candles[index]

    # 短期SMAと長期SMAの計算に必要な本数がそろうまで待ちます。
    required = ("fast_sma", "slow_sma")
    if any(previous[name] is None or current[name] is None for name in required):
        return False

    # 現在の15分足が確定した時点で、各上位足のトレンドを調べます。
    signal_time = current["close_time"]
    higher_trends = [trend_at(lookup, signal_time) for lookup in trend_lookups.values()]
    if any(trend is None for trend in higher_trends):
        return False

    # 短期SMAが長期SMAを下から上へ抜けたか確認します。
    crossed_up = (
        previous["fast_sma"] <= previous["slow_sma"]
        and current["fast_sma"] > current["slow_sma"]
    )
    # ゴールデンクロスが発生し、すべての上位足が上昇方向なら買いです。
    if crossed_up and all(trend == 1 for trend in higher_trends):
        return True
    return False


def exit_from_candle(position, candle):
    """15分足の高値・安値から損切りまたは利益確定を判定する。"""

    # 設定したpipsを価格差へ変換し、損切り価格と利益確定価格を作ります。
    entry_price = position["entry_price"]
    stop_price = entry_price - config.STOP_LOSS_PIPS * config.PIP_SIZE
    take_price = entry_price + config.TAKE_PROFIT_PIPS * config.PIP_SIZE
    # 安値が損切り価格以下なら損切り、高値が利益確定価格以上なら利益確定です。
    stop_hit = candle["low"] <= stop_price
    take_hit = candle["high"] >= take_price

    # 1本の足で両方に触れた場合は、設定ファイルで優先順位を決めます。
    if stop_hit and take_hit:
        if config.STOP_FIRST_WHEN_BOTH_HIT:
            return stop_price, "損切り"
        return take_price, "利益確定"
    if stop_hit:
        return stop_price, "損切り"
    if take_hit:
        return take_price, "利益確定"
    return None


def close_position(position, exit_time, exit_price, exit_reason):
    """決済価格からpips損益と金額の損益を計算する。"""

    # ロングなので「決済価格 - エントリー価格」が値動きによる損益です。
    profit_pips = (exit_price - position["entry_price"]) / config.PIP_SIZE

    # pipsを価格差へ戻し、取引数量を掛けて概算損益を求めます。
    profit = profit_pips * config.PIP_SIZE * config.POSITION_SIZE_UNITS

    # trades.csvへ1行として保存できる辞書を返します。
    return {
        "売買方向": "ロング",
        "エントリー日時": position["entry_time"].isoformat(),
        "エントリー価格": f'{position["entry_price"]:.6f}',
        "決済日時": exit_time.isoformat(),
        "決済価格": f"{exit_price:.6f}",
        "決済理由": exit_reason,
        "保有本数": position["holding_bars"],
        "損益(pips)": f"{profit_pips:.2f}",
        "損益(円)": f"{profit:.0f}",
    }


def run_backtest(candles, trend_lookups):
    """15分足を時刻順に処理し、取引履歴を作る。"""

    # tradesは完了した取引、positionは現在保有している買いポジションです。
    trades = []
    position = None

    # シグナルが出た足の終値ではなく、次の足の始値でエントリーするための印です。
    pending_long_entry = False

    # 15分足を古いものから1本ずつ処理します。
    for index, candle in enumerate(candles):
        # 直前の足で確定したシグナルを、現在足の始値で約定します。
        if position is None and pending_long_entry:
            position = {
                "entry_time": candle["open_time"],
                "entry_price": candle["open"],
                "holding_bars": 0,
            }
        # 一度確認したシグナルはここでリセットします。
        pending_long_entry = False

        # ポジションを持っている間は、現在足で決済条件に触れたか確認します。
        if position is not None:
            position["holding_bars"] += 1
            exit_result = exit_from_candle(position, candle)
            if exit_result is not None:
                exit_price, reason = exit_result
                # 決済した取引を履歴へ追加し、ポジションを空に戻します。
                trades.append(close_position(position, candle["close_time"], exit_price, reason))
                position = None
        # この足の終値でシグナルを判定し、次の足まで保留します。
        if position is None:
            pending_long_entry = has_long_entry_signal(index, candles, trend_lookups)

    # データ末尾で保有中なら、最後の終値で決済します。
    if position is not None:
        last = candles[-1]
        trades.append(close_position(position, last["close_time"], last["close"], "データ終了"))
    return trades


def calculate_summary(trades):
    """取引履歴から主な評価指標を作る。"""

    # 取引ごとの損益を順番に足し、最大ドローダウンを計算します。
    cumulative_profit = 0.0
    peak_profit = 0.0
    max_drawdown = 0.0
    for trade in trades:
        cumulative_profit += float(trade["損益(円)"])
        peak_profit = max(peak_profit, cumulative_profit)
        drawdown = peak_profit - cumulative_profit
        max_drawdown = max(max_drawdown, drawdown)

    # 利益がプラスの取引を勝ち、マイナスの取引を負けとして分けます。
    profits = [float(trade["損益(円)"]) for trade in trades]
    wins = [profit for profit in profits if profit > 0]
    losses = [profit for profit in profits if profit < 0]
    gross_profit = sum(wins)
    gross_loss = abs(sum(losses))
    # プロフィットファクターは「総利益 ÷ 総損失」です。
    profit_factor = gross_profit / gross_loss if gross_loss else None

    # summary.csvへ1行で出力する集計結果を作ります。
    summary = {
        "通貨ペア": config.SYMBOL,
        "取引回数": len(trades),
        "勝ち取引数": len(wins),
        "負け取引数": len(losses),
        "勝率(%)": f"{(len(wins) / len(trades) * 100) if trades else 0:.2f}",
        "合計損益(円)": f"{sum(profits):.0f}",
        "合計損益(pips)": f'{sum(float(trade["損益(pips)"]) for trade in trades):.2f}',
        "プロフィットファクター": f"{profit_factor:.2f}" if profit_factor is not None else "計算不可",
        "最大ドローダウン(円)": f"{max_drawdown:.0f}",
    }
    return summary


def make_output_dir():
    """実行日時を使って結果の保存先を作る。"""

    # 実行日時を「20260803_223000」のような文字列にします。
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_dir = config.OUTPUT_BASE_DIR / timestamp
    output_dir.mkdir(parents=True)
    return output_dir


def write_csv(path, rows, fieldnames):
    """指定した列の順番で、結果をCSVへ保存する。"""

    # 保存先フォルダがなければ作成します。
    path.parent.mkdir(parents=True, exist_ok=True)

    # "w"は新規書き込みです。日時別フォルダなので以前の結果は上書きしません。
    with path.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def main():
    """CSVの読み込みから結果の保存までを順番に実行する。"""

    # 設定ファイルに書かれた4種類のローソク足CSVを読み込みます。
    print(f"{config.SYMBOL}のローソク足CSVを読み込んでいます。")
    all_candles = {
        interval: load_candles(interval, path)
        for interval, path in config.CANDLE_CSV_PATHS.items()
    }

    # エントリー判断に使う15分足へ、短期SMAと長期SMAを追加します。
    entry_candles = all_candles["15min"]
    add_sma(entry_candles, config.ENTRY_FAST_SMA_PERIOD, "fast_sma")
    add_sma(entry_candles, config.ENTRY_SLOW_SMA_PERIOD, "slow_sma")

    # 1時間足、4時間足、日足へSMAを追加し、トレンドを検索できる形にします。
    trend_lookups = {}
    for interval, period in config.TREND_SMA_PERIODS.items():
        sma_column = "trend_sma"
        add_sma(all_candles[interval], period, sma_column)
        trend_lookups[interval] = make_completed_trend_lookup(
            all_candles[interval], sma_column
        )

    # 時刻順にバックテストし、取引履歴と集計結果を作ります。
    trades = run_backtest(entry_candles, trend_lookups)
    summary = calculate_summary(trades)

    # 実行日時のフォルダを作り、取引履歴と集計結果をCSVへ保存します。
    output_dir = make_output_dir()
    write_csv(output_dir / "trades.csv", trades, TRADE_COLUMNS)
    write_csv(output_dir / "summary.csv", [summary], list(summary.keys()))

    print(f'取引回数: {summary["取引回数"]}回')
    print(f'勝率: {summary["勝率(%)"]}%')
    print(f'合計損益: {summary["合計損益(円)"]}円')
    print(f'最大ドローダウン: {summary["最大ドローダウン(円)"]}円')
    print(f"結果の保存先: {output_dir}")


# このファイルを直接実行したときだけmain()を動かします。
if __name__ == "__main__":
    main()
backtest_config.py(Pythonスクリプト)
from pathlib import Path

# 検証する通貨ペアと価格種別です。
SYMBOL = "USD_JPY"
PRICE_TYPE = "ASK"

# fetch_gmo_candles.py が出力したCSVを読み込む場所です。
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data" / "candles" / SYMBOL
CANDLE_CSV_PATHS = {
    "1day": DATA_DIR / f"1day_{PRICE_TYPE}.csv",
    "4hour": DATA_DIR / f"4hour_{PRICE_TYPE}.csv",
    "1hour": DATA_DIR / f"1hour_{PRICE_TYPE}.csv",
    "15min": DATA_DIR / f"15min_{PRICE_TYPE}.csv",
}

# 結果を保存する親フォルダです。
# 実行時に、この下へ YYYYMMDD_HHMMSS 形式のフォルダが作られます。
OUTPUT_BASE_DIR = BASE_DIR / "data" / "backtests" / SYMBOL

# 売買ルールです。
# 15分足の短期・長期移動平均線が交差したときにエントリーを検討します。
ENTRY_FAST_SMA_PERIOD = 10
ENTRY_SLOW_SMA_PERIOD = 30

# 上位足は、終値が移動平均線より上なら買い方向、下なら売り方向と判定します。
TREND_SMA_PERIODS = {
    "1hour": 20,
    "4hour": 20,
    "1day": 20,
}

# このサンプルではロング(買い)のみを検証します。
# 損切りと利益確定の幅をpips単位で設定します。
STOP_LOSS_PIPS = 20.0
TAKE_PROFIT_PIPS = 40.0

# USD/JPYなど、円を含む通貨ペアは通常 1pips = 0.01円です。
PIP_SIZE = 0.01

# 1回の取引数量です。USD/JPYの場合、損益の単位は概算で円になります。
POSITION_SIZE_UNITS = 10000

# 同じ15分足の中で損切りと利益確定の両方に触れた場合、保守的に損切りを優先します。
STOP_FIRST_WHEN_BOTH_HIT = True

FXのバックテストとは?過去データで売買ルールを検証する仕組み

FXのバックテストとは、過去の為替レートを使って、あらかじめ決めた売買ルールがどのような結果になるかを確認する方法です。

過去のローソク足を古い順番に読み込み、設定したエントリー条件や決済条件に当てはめて、取引結果を計算します。

例えば、

  • 短期の移動平均線が長期の移動平均線を上回ったら買う
  • エントリー価格から40pips上昇したら利益確定する
  • エントリー価格から20pips下落したら損切りする

といったルールを検証できます。

検証後は、取引回数や勝率、合計損益などを確認しましょう。

実際に資金を使う前に売買ルールの特徴や改善点を確認できますが、過去のデータで良い結果が出ても、将来の利益が保証されるわけではないため、注意が必要です。

PythonでFXのバックテストを行う3ステップ

ここからは、Pythonスクリプトを使って、CSVに保存したローソク足データをバックテストする方法を紹介します。

今回のスクリプトでは、15分足の移動平均線によるエントリー条件と、1時間足・4時間足・日足のトレンド条件を組み合わせて、ロング取引の結果を検証します

通貨ペアやSMAの期間、利益確定・損切りの幅などは、コンフィグファイルから変更できます。

Pythonでバックテストを実行する流れは、以下の3ステップです。

  • バックテスト用のローソク足データを準備する
  • コンフィグで検証条件を設定する
  • Pythonスクリプトを実行して結果を確認する

1つずつ詳しく見ていきましょう。

ステップ1:バックテスト用のローソク足データを準備する

最初に、バックテストで使用するローソク足CSVとPythonスクリプトを準備しましょう。

今回のスクリプトでは、以下の4種類のローソク足CSVを使用します。

  • 15min_ASK.csv
  • 1hour_ASK.csv
  • 4hour_ASK.csv
  • 1day_ASK.csv

ローソク足のデータをまだ取得していない場合は、以下の記事を参考に、GMOコインFX APIからCSVを取得してください。

続いて、ローソク足CSVを保存しているフォルダに、以下の2つのPythonファイルを準備します。

backtest_fx.py         # バックテストを実行するファイル
backtest_config.py   # 通貨ペアやSMAの期間、利益確定・損切りなどの条件を設定するファイル

フォルダ構成は、以下のようになります。

GMO-API/
├─ backtest_fx.py
├─ backtest_config.py
└─ data/
   └─ candles/
      └─ USD_JPY/
         ├─ 15min_ASK.csv
         ├─ 1hour_ASK.csv
         ├─ 4hour_ASK.csv
         └─ 1day_ASK.csv

作成したbacktest_fx.pybacktest_config.pyへ、以下のコードをコピーして保存してください。

↓ ↓ ↓ それぞれクリックすると開けます ↓ ↓ ↓

backtest_fx.py(Pythonスクリプト)
import bisect
import csv
from collections import deque
from datetime import datetime, timedelta

import backtest_config as config

# 日足・4時間足・1時間足が上昇トレンドのとき、15分足のゴールデンクロスで買い、
# 30pipsの損切りまたは40pipsの利益確定で決済するロング専用EAを検証します。

# 使い方:
# 1. backtest_config.py の設定値を必要に応じて変更します。
# 2. GMO-API フォルダで次のコマンドを実行します。
#    python backtest_fx.py
# 3. 結果は data/backtests/USD_JPY/YYYYMMDD_HHMMSS フォルダへ保存されます。
#
# 上の import backtest_config as config で設定ファイルを読み込んでいます。
# このサンプルはロング(買い)だけをバックテストします。
# ASK価格だけを使う基礎サンプルのため、スプレッドなどの取引コストは考慮しません。

# -----------------------------------------------------------------------------
# バックテストで使用する基本情報
# -----------------------------------------------------------------------------

# 各時間足のローソク足1本が、何分または何時間なのかを設定します。
# CSVのtimestamp_jstはローソク足の開始時刻なので、この時間を足すことで
# ローソク足が確定した時刻を求めます。
INTERVAL_DURATIONS = {
    "15min": timedelta(minutes=15),
    "1hour": timedelta(hours=1),
    "4hour": timedelta(hours=4),
    "1day": timedelta(days=1),
}

# trades.csvへ出力する日本語の列名と、その並び順です。
TRADE_COLUMNS = [
    "売買方向", "エントリー日時", "エントリー価格", "決済日時", "決済価格",
    "決済理由", "保有本数", "損益(pips)", "損益(円)",
]


def load_candles(interval, csv_path):
    """fetch_gmo_candles.py形式のCSVを読み込む。"""

    # 指定したCSVがない場合は、対象の時間足を示して処理を止めます。
    if not csv_path.exists():
        raise FileNotFoundError(
            f"CSVが見つかりません: {csv_path}\n"
            f"fetch_gmo_candles.pyで {interval} のデータを取得してください。"
        )

    # CSVの各行を、バックテストで扱いやすい辞書へ変換して格納します。
    candles = []
    with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file)

        # 必要な5列がCSVにそろっているか確認します。
        required_columns = {"timestamp_jst", "open", "high", "low", "close"}
        if not required_columns.issubset(reader.fieldnames or []):
            raise ValueError(f"CSVの列が不足しています: {csv_path}")

        for row in reader:
            # 日時はdatetime、価格はfloatへ変換します。
            # 文字列のままでは日時の比較や価格の計算がしにくいためです。
            candles.append(
                {
                    "open_time": datetime.fromisoformat(row["timestamp_jst"]),
                    "open": float(row["open"]),
                    "high": float(row["high"]),
                    "low": float(row["low"]),
                    "close": float(row["close"]),
                }
            )

    # CSVの並び順に関係なく、古いローソク足から順番に並べます。
    candles.sort(key=lambda candle: candle["open_time"])
    for candle in candles:
        # 未完成の上位足を参照しないよう、利用可能になる時刻を記録します。
        candle["close_time"] = candle["open_time"] + INTERVAL_DURATIONS[interval]
    return candles


def add_sma(candles, period, column_name):
    """各ローソク足へ単純移動平均線を追加する。"""

    # windowには、SMAの計算に必要な直近の終値だけを残します。
    window = deque()
    window_sum = 0.0
    for candle in candles:
        # 新しい終値を計算対象へ加えます。
        window.append(candle["close"])
        window_sum += candle["close"]

        # 指定期間より古い終値は計算対象から外します。
        if len(window) > period:
            window_sum -= window.popleft()

        # 必要な本数がそろうまではSMAを計算できないためNoneを入れます。
        candle[column_name] = window_sum / period if len(window) == period else None


def make_completed_trend_lookup(candles, sma_column):
    """指定時刻までに確定した上位足のトレンドを検索できる形にする。"""

    # SMAを計算できたローソク足だけをトレンド判定に使います。
    usable = [candle for candle in candles if candle[sma_column] is not None]
    close_times = [candle["close_time"] for candle in usable]

    # 終値がSMAより上なら1、下なら-1、同じなら0です。
    trends = [
        1 if candle["close"] > candle[sma_column]
        else -1 if candle["close"] < candle[sma_column]
        else 0
        for candle in usable
    ]
    return close_times, trends


def trend_at(lookup, signal_time):
    """signal_timeまでに確定済みの最新トレンドを返す。"""

    close_times, trends = lookup

    # bisectを使い、シグナル時刻以前に確定した最新の上位足を探します。
    # 形成途中の上位足を使わないことで、未来の情報を参照するのを防ぎます。
    index = bisect.bisect_right(close_times, signal_time) - 1
    return trends[index] if index >= 0 else None


def has_long_entry_signal(index, candles, trend_lookups):
    """確定した15分足と上位足から、買いシグナルの有無を返す。"""

    # 最初の1本は直前のSMAと比較できないため、エントリーしません。
    if index == 0:
        return False

    # 直前の15分足と、現在の15分足を取り出します。
    previous = candles[index - 1]
    current = candles[index]

    # 短期SMAと長期SMAの計算に必要な本数がそろうまで待ちます。
    required = ("fast_sma", "slow_sma")
    if any(previous[name] is None or current[name] is None for name in required):
        return False

    # 現在の15分足が確定した時点で、各上位足のトレンドを調べます。
    signal_time = current["close_time"]
    higher_trends = [trend_at(lookup, signal_time) for lookup in trend_lookups.values()]
    if any(trend is None for trend in higher_trends):
        return False

    # 短期SMAが長期SMAを下から上へ抜けたか確認します。
    crossed_up = (
        previous["fast_sma"] <= previous["slow_sma"]
        and current["fast_sma"] > current["slow_sma"]
    )
    # ゴールデンクロスが発生し、すべての上位足が上昇方向なら買いです。
    if crossed_up and all(trend == 1 for trend in higher_trends):
        return True
    return False


def exit_from_candle(position, candle):
    """15分足の高値・安値から損切りまたは利益確定を判定する。"""

    # 設定したpipsを価格差へ変換し、損切り価格と利益確定価格を作ります。
    entry_price = position["entry_price"]
    stop_price = entry_price - config.STOP_LOSS_PIPS * config.PIP_SIZE
    take_price = entry_price + config.TAKE_PROFIT_PIPS * config.PIP_SIZE
    # 安値が損切り価格以下なら損切り、高値が利益確定価格以上なら利益確定です。
    stop_hit = candle["low"] <= stop_price
    take_hit = candle["high"] >= take_price

    # 1本の足で両方に触れた場合は、設定ファイルで優先順位を決めます。
    if stop_hit and take_hit:
        if config.STOP_FIRST_WHEN_BOTH_HIT:
            return stop_price, "損切り"
        return take_price, "利益確定"
    if stop_hit:
        return stop_price, "損切り"
    if take_hit:
        return take_price, "利益確定"
    return None


def close_position(position, exit_time, exit_price, exit_reason):
    """決済価格からpips損益と金額の損益を計算する。"""

    # ロングなので「決済価格 - エントリー価格」が値動きによる損益です。
    profit_pips = (exit_price - position["entry_price"]) / config.PIP_SIZE

    # pipsを価格差へ戻し、取引数量を掛けて概算損益を求めます。
    profit = profit_pips * config.PIP_SIZE * config.POSITION_SIZE_UNITS

    # trades.csvへ1行として保存できる辞書を返します。
    return {
        "売買方向": "ロング",
        "エントリー日時": position["entry_time"].isoformat(),
        "エントリー価格": f'{position["entry_price"]:.6f}',
        "決済日時": exit_time.isoformat(),
        "決済価格": f"{exit_price:.6f}",
        "決済理由": exit_reason,
        "保有本数": position["holding_bars"],
        "損益(pips)": f"{profit_pips:.2f}",
        "損益(円)": f"{profit:.0f}",
    }


def run_backtest(candles, trend_lookups):
    """15分足を時刻順に処理し、取引履歴を作る。"""

    # tradesは完了した取引、positionは現在保有している買いポジションです。
    trades = []
    position = None

    # シグナルが出た足の終値ではなく、次の足の始値でエントリーするための印です。
    pending_long_entry = False

    # 15分足を古いものから1本ずつ処理します。
    for index, candle in enumerate(candles):
        # 直前の足で確定したシグナルを、現在足の始値で約定します。
        if position is None and pending_long_entry:
            position = {
                "entry_time": candle["open_time"],
                "entry_price": candle["open"],
                "holding_bars": 0,
            }
        # 一度確認したシグナルはここでリセットします。
        pending_long_entry = False

        # ポジションを持っている間は、現在足で決済条件に触れたか確認します。
        if position is not None:
            position["holding_bars"] += 1
            exit_result = exit_from_candle(position, candle)
            if exit_result is not None:
                exit_price, reason = exit_result
                # 決済した取引を履歴へ追加し、ポジションを空に戻します。
                trades.append(close_position(position, candle["close_time"], exit_price, reason))
                position = None
        # この足の終値でシグナルを判定し、次の足まで保留します。
        if position is None:
            pending_long_entry = has_long_entry_signal(index, candles, trend_lookups)

    # データ末尾で保有中なら、最後の終値で決済します。
    if position is not None:
        last = candles[-1]
        trades.append(close_position(position, last["close_time"], last["close"], "データ終了"))
    return trades


def calculate_summary(trades):
    """取引履歴から主な評価指標を作る。"""

    # 取引ごとの損益を順番に足し、最大ドローダウンを計算します。
    cumulative_profit = 0.0
    peak_profit = 0.0
    max_drawdown = 0.0
    for trade in trades:
        cumulative_profit += float(trade["損益(円)"])
        peak_profit = max(peak_profit, cumulative_profit)
        drawdown = peak_profit - cumulative_profit
        max_drawdown = max(max_drawdown, drawdown)

    # 利益がプラスの取引を勝ち、マイナスの取引を負けとして分けます。
    profits = [float(trade["損益(円)"]) for trade in trades]
    wins = [profit for profit in profits if profit > 0]
    losses = [profit for profit in profits if profit < 0]
    gross_profit = sum(wins)
    gross_loss = abs(sum(losses))
    # プロフィットファクターは「総利益 ÷ 総損失」です。
    profit_factor = gross_profit / gross_loss if gross_loss else None

    # summary.csvへ1行で出力する集計結果を作ります。
    summary = {
        "通貨ペア": config.SYMBOL,
        "取引回数": len(trades),
        "勝ち取引数": len(wins),
        "負け取引数": len(losses),
        "勝率(%)": f"{(len(wins) / len(trades) * 100) if trades else 0:.2f}",
        "合計損益(円)": f"{sum(profits):.0f}",
        "合計損益(pips)": f'{sum(float(trade["損益(pips)"]) for trade in trades):.2f}',
        "プロフィットファクター": f"{profit_factor:.2f}" if profit_factor is not None else "計算不可",
        "最大ドローダウン(円)": f"{max_drawdown:.0f}",
    }
    return summary


def make_output_dir():
    """実行日時を使って結果の保存先を作る。"""

    # 実行日時を「20260803_223000」のような文字列にします。
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_dir = config.OUTPUT_BASE_DIR / timestamp
    output_dir.mkdir(parents=True)
    return output_dir


def write_csv(path, rows, fieldnames):
    """指定した列の順番で、結果をCSVへ保存する。"""

    # 保存先フォルダがなければ作成します。
    path.parent.mkdir(parents=True, exist_ok=True)

    # "w"は新規書き込みです。日時別フォルダなので以前の結果は上書きしません。
    with path.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def main():
    """CSVの読み込みから結果の保存までを順番に実行する。"""

    # 設定ファイルに書かれた4種類のローソク足CSVを読み込みます。
    print(f"{config.SYMBOL}のローソク足CSVを読み込んでいます。")
    all_candles = {
        interval: load_candles(interval, path)
        for interval, path in config.CANDLE_CSV_PATHS.items()
    }

    # エントリー判断に使う15分足へ、短期SMAと長期SMAを追加します。
    entry_candles = all_candles["15min"]
    add_sma(entry_candles, config.ENTRY_FAST_SMA_PERIOD, "fast_sma")
    add_sma(entry_candles, config.ENTRY_SLOW_SMA_PERIOD, "slow_sma")

    # 1時間足、4時間足、日足へSMAを追加し、トレンドを検索できる形にします。
    trend_lookups = {}
    for interval, period in config.TREND_SMA_PERIODS.items():
        sma_column = "trend_sma"
        add_sma(all_candles[interval], period, sma_column)
        trend_lookups[interval] = make_completed_trend_lookup(
            all_candles[interval], sma_column
        )

    # 時刻順にバックテストし、取引履歴と集計結果を作ります。
    trades = run_backtest(entry_candles, trend_lookups)
    summary = calculate_summary(trades)

    # 実行日時のフォルダを作り、取引履歴と集計結果をCSVへ保存します。
    output_dir = make_output_dir()
    write_csv(output_dir / "trades.csv", trades, TRADE_COLUMNS)
    write_csv(output_dir / "summary.csv", [summary], list(summary.keys()))

    print(f'取引回数: {summary["取引回数"]}回')
    print(f'勝率: {summary["勝率(%)"]}%')
    print(f'合計損益: {summary["合計損益(円)"]}円')
    print(f'最大ドローダウン: {summary["最大ドローダウン(円)"]}円')
    print(f"結果の保存先: {output_dir}")


# このファイルを直接実行したときだけmain()を動かします。
if __name__ == "__main__":
    main()
backtest_config.py(Pythonスクリプト)
from pathlib import Path

# 検証する通貨ペアと価格種別です。
SYMBOL = "USD_JPY"
PRICE_TYPE = "ASK"

# fetch_gmo_candles.py が出力したCSVを読み込む場所です。
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data" / "candles" / SYMBOL
CANDLE_CSV_PATHS = {
    "1day": DATA_DIR / f"1day_{PRICE_TYPE}.csv",
    "4hour": DATA_DIR / f"4hour_{PRICE_TYPE}.csv",
    "1hour": DATA_DIR / f"1hour_{PRICE_TYPE}.csv",
    "15min": DATA_DIR / f"15min_{PRICE_TYPE}.csv",
}

# 結果を保存する親フォルダです。
# 実行時に、この下へ YYYYMMDD_HHMMSS 形式のフォルダが作られます。
OUTPUT_BASE_DIR = BASE_DIR / "data" / "backtests" / SYMBOL

# 売買ルールです。
# 15分足の短期・長期移動平均線が交差したときにエントリーを検討します。
ENTRY_FAST_SMA_PERIOD = 10
ENTRY_SLOW_SMA_PERIOD = 30

# 上位足は、終値が移動平均線より上なら買い方向、下なら売り方向と判定します。
TREND_SMA_PERIODS = {
    "1hour": 20,
    "4hour": 20,
    "1day": 20,
}

# このサンプルではロング(買い)のみを検証します。
# 損切りと利益確定の幅をpips単位で設定します。
STOP_LOSS_PIPS = 20.0
TAKE_PROFIT_PIPS = 40.0

# USD/JPYなど、円を含む通貨ペアは通常 1pips = 0.01円です。
PIP_SIZE = 0.01

# 1回の取引数量です。USD/JPYの場合、損益の単位は概算で円になります。
POSITION_SIZE_UNITS = 10000

# 同じ15分足の中で損切りと利益確定の両方に触れた場合、保守的に損切りを優先します。
STOP_FIRST_WHEN_BOTH_HIT = True

   

ステップ2:コンフィグで検証条件を設定する

ローソク足CSVとスクリプトを準備したら、backtest_config.pyを開いて検証条件を設定します。

今回のスクリプトでは、Pythonの処理部分を変更しなくても、コンフィグにある数値を変更することで、条件を変えて再検証できます

主な設定項目は、以下のとおりです。

設定項目内容初期設定
SYMBOL検証する通貨ペア"USD_JPY"
PRICE_TYPE使用する価格種別"ASK"
ENTRY_FAST_SMA_PERIOD15分足の短期SMA10
ENTRY_SLOW_SMA_PERIOD15分足の長期SMA30
TREND_SMA_PERIODS上位足のSMA期間20
STOP_LOSS_PIPS損切り幅20.0
TAKE_PROFIT_PIPS利益確定幅40.0
PIP_SIZE1pipsの価格幅0.01
POSITION_SIZE_UNITS1回の取引数量10,000
STOP_FIRST_WHEN_BOTH_HITTP・SL同時到達時の判定損切り優先

初期設定では、15分足の短期SMA10が長期SMA30を下から上へ抜け、1時間足・4時間足・日足がすべて上昇トレンドの場合にロングでエントリーします。

利益確定は40pips、損切りは20pipsです。

ENTRY_FAST_SMA_PERIOD = 10
ENTRY_SLOW_SMA_PERIOD = 30

STOP_LOSS_PIPS = 20.0
TAKE_PROFIT_PIPS = 40.0

上位足では、1時間足・4時間足・日足のそれぞれで20期間のSMAを使用します。

TREND_SMA_PERIODS = {
    "1hour": 20,
    "4hour": 20,
    "1day": 20,
}

また、USD/JPYなど右側がJPYの通貨ペアでは、通常PIP_SIZE0.01を設定します。

EUR/USDなど右側がJPYではない通貨ペアを検証する場合は、通常0.0001へ変更してください。

ステップ3:Pythonスクリプトを実行して結果を確認する

設定が完了したら、PowerShellからPythonスクリプトを実行しましょう。

まず、PowerShellを開き、backtest_fx.pyを保存したフォルダへ移動してください。

cd "C:\Users\ユーザー名\Documents\GMO-API"

保存場所に合わせて、フォルダのパスは変更してください。

フォルダへ移動したら、以下のコマンドを実行します。

python backtest_fx.py

スクリプトを実行すると、4種類のローソク足CSVが読み込まれ、設定した条件でバックテストが行われます

処理が完了すると、PowerShellに以下のような結果が表示されます。

バックテスト結果は、実行日時ごとに作成されるフォルダへ保存されます。

GMO-API/
└─ data/
   └─ backtests/
      └─ USD_JPY/
         └─ 20260803_223000/
            ├─ summary.csv
            └─ trades.csv

保存されるファイルの内容は以下です。

  • summary.csv:取引回数や勝率、合計損益などの全体結果
  • trades.csv:エントリー日時や決済日時、決済理由、保有本数、取引ごとの損益など

実行するたびに日時の異なるフォルダが作成されるため、以前のバックテスト結果は上書きされません。

バックテスト結果の見方と確認すべき指標

バックテスト結果は、summary.csvtrades.csvを確認し、勝率や合計損益、プロフィットファクター、最大ドローダウンなどを見ながら評価しましょう。

主に確認すべき内容は、以下の3つです。

  • summary.csvtrades.csvの内容を確認する
  • 勝率・利益・PF・最大ドローダウンを確認する
  • 設定を変更してバックテスト結果を改善する

1つずつ詳しく見ていきましょう。

summary.csvとtrades.csvの内容を確認する

summary.csv」には、バックテスト全体の結果が1行で保存されます。

今回の初期設定では、以下の結果になりました。

通貨ペア取引回数勝ち負け勝率合計損益合計損益PF最大ドローダウン
USD_JPY65回21回44回32.31%-4,000円-40.00pips0.9526,000円

PF(プロフィットファクター)は、総利益を総損失で割った数値です。1を上回ると総利益が総損失より大きく、1を下回ると総損失のほうが大きいことを表します。

最大ドローダウンは、累積利益が最も高かった時点から、その後どれだけ損失が拡大したかを表す数値です。値が小さいほど、資金の減少を抑えられていると判断できます。

summary.csvでは、売買ルールの成績を全体的に確認できます。

一方、trades.csvには、1回ごとの取引結果が保存されます。

項目内容
売買方向ロングまたはショート
エントリー日時ポジションを持った日時
エントリー価格エントリー時の価格
決済日時ポジションを決済した日時
決済価格決済時の価格
決済理由利益確定・損切り・データ終了
保有本数保有していた15分足の本数
損益(pips)取引ごとのpips損益
損益(円)取引ごとの金額損益

例えば、最初の取引結果は以下のとおりです。

ロング
エントリー日時:2026-02-05 15:30
エントリー価格:156.938
決済日時:2026-02-05 19:00
決済価格:157.338
決済理由:利益確定
損益:40pips/4,000円

summary.csv」で全体の成績を確認し、気になる取引があれば「trades.csv」で詳しい内容を確認しましょう。

スクリプトでは、取引履歴から勝率、合計損益、プロフィットファクター、最大ドローダウンなどを計算してsummary.csvへ保存しています。

勝率・利益・PF・最大ドローダウンを確認する

バックテスト結果を見るときは、1つの指標だけで売買ルールを評価しないことが大切です。

今回の結果では、65回の取引のうち21回が利益確定44回が損切りとなり、勝率は32.31%でした。

利益確定は40pips、損切りは20pipsに設定しているため、1回の利益が1回の損失より大きくなっています。

しかし、負け取引が多かったため、合計損益はマイナス4,000円になりました。

主な指標の見方は以下のとおりです。

指標確認できること
取引回数検証結果として十分な取引数があるか
勝率全取引のうち利益になった割合
合計損益最終的に利益または損失がいくら残ったか
合計損益(pips)取引数量に左右されない値幅の損益
PF(プロフィットファクター)総利益が総損失の何倍あるか
最大ドローダウン累積利益のピークから最大でいくら減少したか

PF(プロフィットファクター)は、総利益を総損失で割って計算します。

今回の結果は、総利益84,000円、総損失88,000円です。

84,000円 ÷ 88,000円 = 約0.95

プロフィットファクターが1を下回っているため、今回の初期設定では総利益より総損失のほうが大きいことが分かります。

また、最大ドローダウンは26,000円でした。

最大ドローダウンが大きい売買ルールは、最終的に利益が出ていても、途中で大きな損失が発生する可能性があります。

勝率だけでなく、合計損益、プロフィットファクター、最大ドローダウンをあわせて確認しましょう。

設定を変更してバックテスト結果を改善する

今回の初期設定では、合計損益がマイナス4,000円、プロフィットファクターが0.95となりました。

そこで、新しい変数や処理は追加せず、「backtest_config.py」にある既存の設定値だけを変更して、再度バックテストを行いました。

変更した設定は以下のとおりです。

ENTRY_FAST_SMA_PERIOD = 20
ENTRY_SLOW_SMA_PERIOD = 50

STOP_LOSS_PIPS = 30.0
TAKE_PROFIT_PIPS = 40.0

短期SMAを10期間から20期間、長期SMAを30期間から50期間へ変更しています。

また、損切り幅を20pipsから30pipsへ広げました。利益確定は40pipsのままです。

上位足のSMAは変更せず、1時間足・4時間足・日足のすべてで20期間を使用します。

TREND_SMA_PERIODS = {
    "1hour": 20,
    "4hour": 20,
    "1day": 20,
}

変更前後の結果は、以下のとおりです。

指標変更前変更後
取引回数65回39回
勝率32.31%56.41%
合計損益-4,000円37,000円
最大ドローダウン26,000円12,000円
プロフィットファクター0.951.73

変更後は取引回数が65回から39回へ減りましたが、勝率は56.41%まで上昇しました。

合計損益はマイナス4,000円からプラス37,000円となり、最大ドローダウンも26,000円から12,000円へ減少しています。

今回のデータでは、SMAの期間を長くしてエントリー回数を絞り、損切り幅を広げた設定のほうが、勝率・利益・最大ドローダウンのバランスが良い結果となりました。

ただし、この設定は2026年1月から8月までの同じローソク足データを使って選択・評価したものです。

過去のデータに合わせて設定を変更しているため、将来も同じ結果になるとは限りません。

設定を何度も変更して良い結果だけを採用すると、過去の値動きに合わせすぎる「過剰最適化」になる可能性があります。

このPythonスクリプトでバックテストする際の注意点

今回のPythonスクリプトは、CSVのローソク足データを使って売買ルールを簡単に検証できるように作成しています。

ただし、本格的なバックテストと比べて簡略化している処理もあります。

そのため、以下の4点に注意してください。。

  • ASK価格のみを使用するためスプレッドは反映されない
  • 15分足では利益確定と損切りの到達順を判別できない
  • 同じデータで設定を調整すると過剰最適化になりやすい

1つずつ詳しく見ていきましょう。

ASK価格のみを使用するためスプレッドは反映されない

今回のスクリプトでは、エントリーから決済までASK価格のローソク足だけを使用します。

PRICE_TYPE = "ASK"

実際のロング取引では、ASK価格で買い、BID価格で売って決済します。

そのため、ASKとBIDの価格差であるスプレッドが取引コストとして発生します。

しかし、今回のスクリプトはASK価格だけでエントリーと決済を判定するため、スプレッドはバックテスト結果に反映されません

実際の取引では、バックテストよりも利益が小さくなったり、損失が大きくなったりする可能性があります。

今回はバックテストの基本的な流れを分かりやすくするため、スプレッドを考慮しない簡易的な仕組みにしています。

15分足では利益確定と損切りの到達順を判別できない

今回のスクリプトでは、15分足の高値と安値を使って、利益確定または損切りに到達したかを判定します。

ただし、ローソク足から確認できるのは始値・高値・安値・終値だけです。

1本の15分足の中で利益確定価格と損切り価格の両方に到達していた場合、どちらに先に到達したかは判別できません。

そのため、今回の初期設定では、同じローソク足で利益確定と損切りの両方に触れた場合、損切りを優先します

STOP_FIRST_WHEN_BOTH_HIT = True

利益確定を優先すると結果を良く見せる可能性があるため、損切りを優先することで保守的に計算しています。

より正確な到達順を確認するには、15分足よりも細かい1分足やティックデータを使った検証が必要です。

同じデータで設定を調整すると過剰最適化になりやすい

バックテストでは、SMAの期間や利益確定・損切りの幅を変更することで、結果を比較できます。

しかし、同じ期間のローソク足データを繰り返し使い、最も良い結果になった設定だけを採用すると、その期間の値動きに合わせすぎる可能性があります。

このように、過去のデータに設定を合わせすぎた状態を「過剰最適化」と呼びます。

今回紹介した変更後の設定も、2026年1月から8月までのCSVを使って設定の選択と評価を行っています。

そのため、変更後の勝率や利益が、将来の相場でも同じように再現されるとは限りません。

設定を調整した後は、設定選びに使用していない別の期間や別の通貨ペアでも検証し、特定のデータだけで良い結果になっていないか確認することが大切です。

FXのバックテストに関してよくある質問

別の通貨ペアでもバックテストできますか?

はい、できます。

backtest_config.pySYMBOLを変更し、対象の通貨ペアに合わせたローソク足CSVを準備してください。

例えば、EUR/USDを検証する場合は、以下のように変更します。

SYMBOL = "EUR_USD"

あわせて、data/candles/EUR_USDフォルダに15分足・1時間足・4時間足・日足のCSVを保存する必要があります。

また、USD/JPYなどのJPY通貨ペアではPIP_SIZE = 0.01、EUR/USDなどの非JPY通貨ペアでは通常PIP_SIZE = 0.0001を設定しまてください。

ショート取引も検証できますか?

今回のスクリプトは、ロング取引のみを対象としています。

短期SMAが長期SMAを下から上へ抜け、上位足がすべて上昇トレンドの場合に買いでエントリーする仕組みです。

そのため、コンフィグの設定値を変更するだけでは、ショート取引を検証できません。

ショート取引を検証するには、デッドクロスや下降トレンドの判定、ショート用の利益確定・損切り計算などをbacktest_fx.pyへ追加する必要があります。

実際のFX取引とバックテスト結果が異なるのはなぜですか?

バックテストは、過去のローソク足データと設定したルールをもとに計算した結果です。

今回のスクリプトではASK価格だけを使用しており、実際の取引で発生するスプレッドやスリッページは反映していません。

また、15分足の高値と安値だけでは、利益確定と損切りのどちらに先に到達したかを正確に判断できない場合があります。

そのほか、相場環境の変化や通信状況、注文の約定価格なども、実際の取引結果に影響します。

そのため、バックテストで良い結果が出ても、実際の取引で同じ成績になるとは限りません。

まとめ

今回は、CSVに保存したローソク足データとPythonスクリプトを使って、FXの売買ルールをバックテストする方法を紹介しました。

今回のスクリプトでは、15分足のSMAによるエントリー条件と、1時間足・4時間足・日足のトレンド条件を組み合わせて、ロング取引の結果を検証できます。

backtest_config.pyのSMA期間や利益確定・損切りの幅を変更すれば、異なる条件で繰り返しバックテストすることも可能です。

バックテストを実行すると、全体の成績をまとめたsummary.csvと、取引ごとの詳細を記録したtrades.csvが保存されます。

結果を確認する際は、勝率や合計損益だけでなく、取引回数、PF、最大ドローダウンなどもあわせて確認しましょう。

今回のコードは、PythonでFXのバックテストを行う基本的な流れを確認するためのサンプルです。

実際に利用する際は、スプレッドが反映されないことや、決済日時・最大ドローダウンが簡易的に計算されること、同じデータに設定を合わせすぎると過剰最適化につながることにも注意してください。

-FX / EA, Python, 個人開発