FX自動売買 Python 個人開発

【Pythonコードあり】GMO FX APIでFX自動売買を実装!ローソク足取得からエントリーまで

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

PythonでFX自動売買を行うには、最新のローソク足データを取得し、売買条件を判定して注文を送信する仕組みが必要です。

しかし、初めてPythonとGMO FX APIを使って自動売買を実装する場合は、

  • 最新のローソク足データをどのように取得するのか
  • 作成した売買ルールをどのようにエントリー判定へ使うのか
  • 利益確定と損切りを含む注文をどのように作成するのか
  • 実際の注文を送信せずに動作確認できるのか

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

そこで本記事では、GMO FX APIとPythonを使って、最新のローソク足データの取得からエントリー注文までを自動化する方法を紹介します。

今回のスクリプトでは、ローソク足データを更新し、作成済みのコンフィグを使ってエントリーを判定します。

シグナルが出た場合は、最新レートからTP・SLを含むIFDOCO注文を自動でするのです。

初期設定ではデモモードで動作するため、実際の注文を送信せずに処理の流れを確認可能です。

記事の後半では、GMOコインの外国為替FX口座とAPIキーを用意し、実注文へ切り替える方法も解説します。

そのまま利用できるPythonのサンプルコードも掲載しているため、ぜひ最後までご覧ください!

FX自動売買のローソク足取得からエントリーまでの全体像

今回のスクリプトは、live_trade.pyを実行することで、ローソク足の更新から注文内容の記録までを順番に処理します。

処理の流れは、以下のとおりです。

最新のローソク足データを取得
(candle_updater.py)
        ↓
作成済みのCSVへ追加
(candle_updater.py)
        ↓
コンフィグを使ってエントリーを判定
(entry_signal.py)
        ↓
最新レートとスプレッドを確認
(live_trade.py)
        ↓
IFDOCO注文を作成
(live_trade.py)
        ↓
デモ発注または実注文
(live_trade.py)
        ↓
注文内容・送信結果をCSVへ記録
(order_recorder.py)

初期設定ではデモモードで動作するため、実際の注文を送信せずに処理の流れを確認できます。

使用するスクリプトや事前準備、実行方法については、次の「ローソク足取得からエントリーまでの実装手順4ステップ」で解説します。

ローソク足取得からエントリーまでを実装する4ステップ

ここからは、GMO FX APIを使って、ローソク足の更新からエントリー注文までを実行する手順を解説します。

実装の流れは、以下の4ステップです。

  • 実行前の準備を行う
  • 必要なPythonファイルを用意する
  • デモモードの設定を確認する
  • live_trade.pyを実行して結果を確認する

それぞれ順番に進めていきましょう。

ステップ1|実行前の準備を行う

今回のスクリプトを実行する前に、エントリー判定に使用するローソク足データとコンフィグを準備します。

今回のサンプルでは、15分足でエントリーを判定し、1時間足・4時間足・日足で相場のトレンドを確認します。

そのため、以下の時間足のCSVを用意してください。

  • 15分足
  • 1時間足
  • 4時間足
  • 日足

ローソク足データをGMO FX APIから取得してCSVへ保存する方法は、以下の記事で解説しています。

次に、通貨ペアや移動平均線、TP・SL、注文数量などを設定するbacktest_config.pyを作成します。

今回のリアルタイム判定では、バックテストで使用したコンフィグと売買条件を再利用します。

コンフィグの作成方法とバックテストの実行方法は、以下の記事を確認してください。

すでにローソク足CSVとコンフィグを用意している場合は、次のステップへ進みましょう。

ステップ2|必要なPythonファイルを用意する

次に、今回の処理に必要なPythonファイルを用意します。

各ファイルは、以下のように同じプロジェクトフォルダへ配置してください。

プロジェクトフォルダ
├─ live_trade.py
├─ live_config.py
├─ backtest_config.py
├─ backtest_fx.py
├─ candle_updater.py
├─ entry_signal.py
├─ order_recorder.py
└─ data
   ├─ candles
   └─ orders

各ファイルの役割は、以下のとおりです。

ファイル役割
live_trade.py各処理を順番に実行する
live_config.pyデモ・実注文などを設定する
backtest_config.py売買条件や注文数量を設定する
backtest_fx.pyバックテストと共通の判定処理を持つ
candle_updater.pyローソク足データを更新する
entry_signal.pyエントリー条件を判定する
order_recorder.py注文内容や送信結果をCSVへ記録する

以下に、今回使用するPythonファイルのサンプルコードを掲載します。

live_config.py
from pathlib import Path

import backtest_config as strategy

# Falseの場合は注文内容を表示するだけで、実際の注文は行いません。
# 実際に注文する場合だけTrueへ変更してください。
ENABLE_REAL_ORDER = False

LIMIT_BUFFER_PIPS = 0.2
MAX_SPREAD_PIPS = 3.0

# GMO FXへ送る価格の小数桁数です。
# USD/JPYは3桁、EUR/USDなど別の通貨ペアでは、その銘柄に合う桁数へ変更してください。
PRICE_DECIMAL_PLACES = 3

ORDER_HISTORY_PATH = (
    Path(__file__).resolve().parent
    / "data"
    / "orders"
    / "order_history.csv"
)

# APIキーはコードへ直接書かず、環境変数から読み込みます。
API_KEY_ENV = "GMO_FX_API_KEY"
SECRET_KEY_ENV = "GMO_FX_SECRET_KEY"

live_trade.py
import hashlib
import hmac
import json
import os
import ssl
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
from urllib.parse import urlencode
from urllib.request import Request, urlopen

import certifi

import backtest_config as strategy
import candle_updater
import entry_signal
import live_config as config
from order_recorder import save_order_record

# 最新レートなど、APIキーなしで利用できるPublic APIの接続先です。
PUBLIC_URL = "https://forex-api.coin.z.com/public"

# 建玉・注文の確認や発注に使用するPrivate APIの接続先です。
PRIVATE_URL = "https://forex-api.coin.z.com/private"

# HTTPS通信時にサーバー証明書を検証するため、certifiのCA証明書を使います。
SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())

# APIから取得した時刻や売買判定の基準を日本時間へそろえます。
JST = timezone(timedelta(hours=9), name="JST")

# 使い方:
# 1. fetch_gmo_candles.pyで、判定に必要な過去のローソク足CSVを作成します。
# 2. live_config.pyで、実注文の有効・無効や最大スプレッドを設定します。
# 3. PowerShellなどでGMO-APIフォルダへ移動します。
# 4. 次のコマンドを実行します。
#    python live_trade.py
# 5. 初期設定ではデモ発注として、注文内容を表示・CSV記録するだけです。
# 6. 実注文する場合だけ、live_config.pyのENABLE_REAL_ORDERをTrueにします。
#
# このスクリプトが行う処理:
# ローソク足更新 → エントリー判定 → 最新レート確認 → IFDOCO注文作成 → 発注記録
#
# 注意:
# このサンプルは注文の送信までを扱います。LIMIT注文が実際に約定したか、
# TP・SLで決済されたか、損益がいくらになったかは確認しません。


def request_api(request):
    """GMO FX APIへリクエストを送り、data部分を返す。"""

    # タイムアウトを設定して、APIから応答がない状態で待ち続けることを防ぎます。
    with urlopen(request, context=SSL_CONTEXT, timeout=20) as response:
        # レスポンス本文はUTF-8のJSONなので、Pythonの辞書へ変換します。
        payload = json.loads(response.read().decode("utf-8"))

    # GMO FX APIは、正常に処理された場合にstatus=0を返します。
    if int(payload.get("status", -1)) != 0:
        raise RuntimeError(f"GMO FX APIエラー: {payload.get('messages')}")

    # 呼び出し元では、共通情報を除いたdata部分だけを扱います。
    return payload.get("data")


def public_get(path, params=None):
    """認証不要のPublic APIへGETリクエストを送る。"""

    # 検索条件がある場合だけ、辞書をURL用のクエリ文字列へ変換します。
    query = f"?{urlencode(params)}" if params else ""

    # Public APIのURLとAPIごとのパス・検索条件を連結します。
    return request_api(Request(PUBLIC_URL + path + query))


def private_get(path, params=None):
    """署名が必要なPrivate APIへGETリクエストを送る。"""

    # 建玉や注文を通貨ペアで絞り込むためのクエリ文字列を作ります。
    query = f"?{urlencode(params)}" if params else ""
    return private_request("GET", path, query=query)


def private_post(path, body):
    """署名が必要なPrivate APIへPOSTリクエストを送る。"""

    # 署名対象と送信内容が同じになるよう、注文内容を余分な空白のないJSONにします。
    body_text = json.dumps(body, separators=(",", ":"))
    return private_request("POST", path, body_text=body_text)


def private_request(method, path, query="", body_text=""):
    """環境変数のAPIキーで署名し、Private APIへリクエストを送る。"""

    # APIキーをソースコードへ直接書かず、live_config.pyで指定した環境変数から取得します。
    api_key = os.environ.get(config.API_KEY_ENV)
    secret_key = os.environ.get(config.SECRET_KEY_ENV)
    if not api_key or not secret_key:
        raise RuntimeError("GMO FXのAPIキーを環境変数へ設定してください。")

    # GMO Private APIのPOST制限を考慮し、連続発注にならないよう間隔を空けます。
    if method == "POST":
        time.sleep(1.2)

    # API-TIMESTAMPには、現在時刻をミリ秒単位のUNIX時刻で指定します。
    timestamp = str(int(time.time() * 1000))

    # GMO FXの仕様に従い、時刻・HTTPメソッド・パス・本文を順番に連結します。
    # GETのクエリ文字列は署名対象へ含めません。
    sign_text = timestamp + method + path + body_text

    # シークレットキーと署名対象文字列から、HMAC-SHA256署名を作成します。
    signature = hmac.new(
        secret_key.encode("ascii"), sign_text.encode("ascii"), hashlib.sha256
    ).hexdigest()

    # Private APIの認証に必要なAPIキー・時刻・署名をヘッダーへ設定します。
    headers = {
        "API-KEY": api_key,
        "API-TIMESTAMP": timestamp,
        "API-SIGN": signature,
    }

    # JSON本文を送信するPOSTリクエストでは、データ形式もヘッダーへ指定します。
    if body_text:
        headers["Content-Type"] = "application/json"

    # GETでは本文を付けず、POSTではJSON文字列をUTF-8へ変換して送信します。
    request = Request(
        PRIVATE_URL + path + query,
        data=body_text.encode("utf-8") if body_text else None,
        headers=headers,
        method=method,
    )
    return request_api(request)


def latest_ticker(symbol):
    """指定した通貨ペアの最新tickerを取得する。"""

    # ticker APIは複数通貨ペアを返すため、設定した通貨ペアだけを探します。
    for row in public_get("/v1/ticker") or []:
        if row.get("symbol") == symbol:
            return row

    # 対象がなければ、後続処理で誤った価格を使わないよう処理を止めます。
    raise RuntimeError(f"{symbol}の最新レートが見つかりません。")


def list_items(data):
    """GMO FX APIの一覧レスポンスをリストへそろえる。"""

    # APIによってdataが直接リストで返る場合は、そのまま利用します。
    if isinstance(data, list):
        return data

    # data内のlistに明細が入る形式にも対応します。
    if isinstance(data, dict):
        return data.get("list", [])

    # データがない場合も、呼び出し元が件数を安全に確認できるよう空リストを返します。
    return []


def format_price(value):
    """価格をコンフィグで指定した小数桁数の文字列に変換する。"""

    # floatのround()ではなくDecimalを使い、指定した小数桁数で四捨五入します。
    decimal_places = config.PRICE_DECIMAL_PLACES
    quantize_unit = Decimal("1").scaleb(-decimal_places)
    rounded = Decimal(str(value)).quantize(quantize_unit, ROUND_HALF_UP)
    return f"{rounded:.{decimal_places}f}"

def make_ifd_oco_order(ask, now):
    """買いLIMITと、利益確定・損切りを組み合わせたIFDOCO注文を作る。"""

    # ロング(買い)はASKを基準にします。
    # 注文を約定しやすくするため、現在のASKに0.2pipsを加えた買い指値を作ります。
    # この0.2pipsはlive_config.pyで変更できます。
    entry_price = ask + config.LIMIT_BUFFER_PIPS * strategy.PIP_SIZE

    # エントリー価格を基準に、コンフィグのpips幅からTP・SL価格を計算します。
    take_profit_price = (
        entry_price + strategy.TAKE_PROFIT_PIPS * strategy.PIP_SIZE
    )
    stop_loss_price = entry_price - strategy.STOP_LOSS_PIPS * strategy.PIP_SIZE

    # clientOrderIdは、自分のスクリプトから送った注文を識別するためのIDです。
    # このサンプルでは、移動平均線を使う戦略を表す「SMA」と実行日時を組み合わせます。
    return {
        "symbol": strategy.SYMBOL,
        "clientOrderId": "SMA" + now.strftime("%Y%m%d%H%M%S"),

        # IFDOCOでは、エントリーと決済条件を1回のAPIリクエストでまとめて登録します。

        # 一次注文: 新しく買いポジションを作るLIMIT注文です。
        "firstSide": "BUY",
        "firstExecutionType": "LIMIT",
        "firstSize": str(strategy.POSITION_SIZE_UNITS),
        "firstPrice": format_price(entry_price),

        # 二次注文: 買ったポジションを決済するための注文です。
        # 一次注文が約定した後、利益確定または損切りのどちらかで売却します。
        # どちらかが成立すると、もう一方はOCOの仕組みで処理されます。
        "secondSize": str(strategy.POSITION_SIZE_UNITS),
        "secondLimitPrice": format_price(take_profit_price),
        "secondStopPrice": format_price(stop_loss_price),
    }

def print_order(order):
    """APIへ送る主な注文内容を、日本語で確認できるように表示する。"""

    print("注文内容")
    print(f"  通貨ペア: {order['symbol']}")
    print(f"  売買方向: 買い({order['firstSide']})")
    print(f"  注文方法: IFDOCO / {order['firstExecutionType']}")
    print(f"  注文数量: {order['firstSize']}")
    print(f"  エントリー価格: {order['firstPrice']}")
    print(f"  利益確定価格: {order['secondLimitPrice']}")
    print(f"  損切り価格: {order['secondStopPrice']}")

def make_order_record(
    now,
    signal,
    order,
    mode,
    status,
    message,
    root_order_id="",
):
    """発注結果と注文内容を、CSVへ保存できる形にまとめる。"""

    return {
        "recorded_at": now.isoformat(),
        "mode": mode,
        "status": status,
        "symbol": order["symbol"],
        "side": order["firstSide"],
        "size": order["firstSize"],
        "entry_price": order["firstPrice"],
        "take_profit_price": order["secondLimitPrice"],
        "stop_loss_price": order["secondStopPrice"],
        "signal_time": signal["close_time"].isoformat(),
        "client_order_id": order["clientOrderId"],
        "root_order_id": root_order_id,
        "message": message,
    }


def root_order_id_from_result(result):
    """IFDOCOレスポンスの3注文に共通する親注文IDを取り出す。"""

    # IFDOCOのレスポンスには、一次注文・利益確定・損切りの情報が
    # リストで返ります。その先頭から、共通のrootOrderIdを取得します。
    if isinstance(result, list) and result and isinstance(result[0], dict):
        return str(result[0].get("rootOrderId", ""))
    return ""


def main():
    # すべての日時判定をそろえるため、現在時刻を日本時間で1回だけ取得します。
    now = datetime.now(JST)
    print(f"処理開始日時: {now.isoformat()}")

    # 作成済みの15分足・1時間足・4時間足・日足CSVへ最新データを追加します。
    print("\n【ローソク足データの更新】")
    candle_updater.update_candle_csvs(now)

    # 更新したCSVを使い、確定済みの最新15分足で買い条件を判定します。
    # シグナルがない場合は、発注処理へ進まず終了します。
    print("\n【エントリー条件の判定】")
    signal = entry_signal.find_latest_signal(now)
    if signal is None:
        print("買いシグナルはありません。")
        return

    # 最新tickerから、買値のASKと売値のBIDを取得します。
    # tickerはPublic APIなので、デモ発注ではAPIキーなしで確認できます。
    print("\n【最新レートの取得】")
    ticker = latest_ticker(strategy.SYMBOL)
    ask = float(ticker["ask"])
    bid = float(ticker["bid"])
    print(f"ASK: {format_price(ask)}")
    print(f"BID: {format_price(bid)}")

    # ASKとBIDの差をpipsへ変換します。
    # スプレッドが設定値より広いと、不利な価格で取引する可能性があるため見送ります。
    spread_pips = (ask - bid) / strategy.PIP_SIZE
    print(f"スプレッド: {spread_pips:.2f} pips")
    if spread_pips > config.MAX_SPREAD_PIPS:
        print(f"スプレッドが広いため見送ります: {spread_pips:.2f} pips")
        return

    # 最新ASKを基準にIFDOCO注文を作り、画面に表示します。
    print("\n【IFDOCO注文の作成】")
    order = make_ifd_oco_order(ask, now)
    print_order(order)

    # デモ発注または実注文の処理を先に行い、結果を変数へ入れます。
    # 注文履歴CSVへの保存は、この後の共通処理で1回だけ行います。
    root_order_id = ""
    order_error = None
    result = None

    # ENABLE_REAL_ORDERがFalseの場合は、GMO FXへ注文を送りません。
    if not config.ENABLE_REAL_ORDER:
        mode = "DEMO"
        status = "DEMO"
        message = "実際の注文は送信していません。"
    else:
        # 実注文時だけ、Private APIで現在の建玉と未約定注文を確認します。
        # どちらかが存在する場合は、ポジションや注文が重なるのを避けるため見送ります。
        positions = list_items(
            private_get("/v1/openPositions", {"symbol": strategy.SYMBOL})
        )
        orders = list_items(
            private_get("/v1/activeOrders", {"symbol": strategy.SYMBOL})
        )
        print(f"現在の建玉: {len(positions)}件")
        print(f"未約定注文: {len(orders)}件")
        if positions or orders:
            print("既存の建玉または未約定注文があるため見送ります。")
            return

        mode = "LIVE"

        # 条件をすべて通過したため、Private APIへIFDOCO注文を送信します。
        try:
            result = private_post("/v1/ifoOrder", order)
            status = "SUBMITTED"
            message = "GMO FXへ注文を送信しました。"
            root_order_id = root_order_id_from_result(result)
        except Exception as error:
            # 通信エラーでもGMO側が注文を受理している可能性があります。
            # 同じ注文を再送信する前に、GMO側の注文一覧を確認してください。
            status = "ERROR"
            message = str(error)
            order_error = error

    # 発注結果が決まったため、CSVへ保存するデータを作ります。
    print("\n【注文内容・送信結果の記録】")
    record = make_order_record(
        now,
        signal,
        order,
        mode=mode,
        status=status,
        message=message,
        root_order_id=root_order_id,
    )
    save_order_record(config.ORDER_HISTORY_PATH, record)
    print(f"保存先: {config.ORDER_HISTORY_PATH}")

    if order_error is not None:
        raise order_error

    if mode == "DEMO":
        print("デモ発注を注文履歴CSVへ記録しました。実注文は行っていません。")
    else:
        # SUBMITTEDは注文受付を表し、LIMIT注文が約定したEXECUTEDとは異なります。
        print("実際の注文をGMO FXへ送信し、注文履歴CSVへ記録しました。")
        print("GMO FXからの注文結果:", result)


# このファイルを直接実行したときだけmain()を動かします。
if __name__ == "__main__":
    main()
candle_updater.py
import csv
import json
import ssl
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
from urllib.request import Request, urlopen

import certifi

import backtest_config as strategy

# 更新するローソク足の時間足です。
# 1時間足以下は日単位、4時間足以上は年単位でGMO APIから取得します。
CANDLE_INTERVALS = ("15min", "1hour", "4hour", "1day")

# CSVに出力する列です。
CSV_COLUMNS = ["timestamp_jst", "open", "high", "low", "close"]

# GMO FXのPublic APIの接続先です。
PUBLIC_URL = "https://forex-api.coin.z.com/public"

# certifiが持っている証明書一覧を使って、HTTPS通信の証明書を検証します。
# Windows環境でCERTIFICATE_VERIFY_FAILEDになる場合の対策です。
SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())

# GMO APIの時刻はUTCのミリ秒で返るため、CSVにはJSTへ変換して保存します。
JST = timezone(timedelta(hours=9), name="JST")


def public_get(path, params=None):
    """GMO FXのPublic APIへGETリクエストを送る。"""

    # APIに渡すパラメータがある場合は、URL用のクエリ文字列へ変換します。
    query = f"?{urlencode(params)}" if params else ""

    # Public APIの接続先、APIごとのパス、クエリ文字列をつないでURLを作ります。
    request = Request(PUBLIC_URL + path + query)

    # GMO APIへアクセスして、返ってきたJSON文字列をPythonの辞書に変換します。
    # 20秒以内に応答がない場合は、待ち続けずタイムアウトにします。
    with urlopen(request, context=SSL_CONTEXT, timeout=20) as response:
        payload = json.loads(response.read().decode("utf-8"))

    # GMO APIのstatusが0以外なら、エラーとして処理を止めます。
    if int(payload.get("status", -1)) != 0:
        raise RuntimeError(f"GMO FX APIエラー: {payload.get('messages')}")

    # payload["data"]に、APIから取得したデータが入っています。
    return payload.get("data")


def fetch_klines(symbol, price_type, interval, date_key):
    """指定した日付のローソク足を取得し、CSV用の形式へ変換する。"""

    # GMO APIへ連続でアクセスしすぎないように、1秒待ってから呼び出します。
    time.sleep(1.0)

    # 通貨ペア、価格種別、時間足、日付を指定してローソク足を取得します。
    # date_keyは、1時間足以下では"20260102"、4時間足以上では"2026"の形式です。
    rows = public_get(
        "/v1/klines",
        {
            "symbol": symbol,
            "priceType": price_type,
            "interval": interval,
            "date": date_key,
        },
    )

    # APIのレスポンスから、CSVに保存するローソク足をこのリストへまとめます。
    result = []
    for row in rows or []:
        # openTimeはUTCのミリ秒なので、日時へ変換してから日本時間へ直します。
        timestamp = datetime.fromtimestamp(
            int(row["openTime"]) / 1000, tz=timezone.utc
        ).astimezone(JST)

        # CSVに必要な開始日時と四本値だけを取り出します。
        result.append(
            {
                "timestamp_jst": timestamp.isoformat(timespec="seconds"),
                "open": row["open"],
                "high": row["high"],
                "low": row["low"],
                "close": row["close"],
            }
        )

    # CSVへ追加できる形式に変換したローソク足を返します。
    return result


def read_csv(path):
    """既存のローソク足CSVを読み込む。"""

    # 初回実行などでCSVがまだ存在しない場合は、空のデータとして扱います。
    if not path.exists():
        return []

    # utf-8-sigを指定し、BOM付き・BOMなしのどちらのCSVも読み込めるようにします。
    with path.open("r", encoding="utf-8-sig", newline="") as file:
        return list(csv.DictReader(file))


def update_candle_csvs(now):
    """作成済みCSVへ、最新のローソク足を重複なく追加する。"""

    # GMOの1時間足以下の日付キーは、JST午前6時に切り替わります。
    # 午前0時から6時までは前日をAPI上の基準日として扱います。
    business_date = (now - timedelta(hours=6)).date()
    print("ローソク足CSVの差分更新を開始します。")

    # CANDLE_INTERVALSで指定した時間足を1つずつ更新します。
    for interval in CANDLE_INTERVALS:
        # 時間足に対応するCSVの保存先をbacktest_config.pyから取得します。
        path = strategy.CANDLE_CSV_PATHS[interval]

        # 保存済みのローソク足を読み込み、新しく取得した足を後から追加します。
        rows = read_csv(path)
        print(f"[{interval}] のデータ取得中...")

        if interval in ("15min", "1hour"):
            # 1時間足以下は日ごとに取得するため、CSVの最終日時を取得開始日にします。
            # CSVが空の場合は、現在の基準日から取得します。
            start_date = (
                datetime.fromisoformat(rows[-1]["timestamp_jst"]).date()
                if rows
                else business_date
            )

            # 日付境界付近の足を取りこぼさないよう、前日から再取得します。
            start_date = min(start_date, business_date) - timedelta(days=1)

            # APIへ渡す日付を"20260102"のようなYYYYMMDD形式で1日ずつ作ります。
            date_keys = [
                (start_date + timedelta(days=offset)).strftime("%Y%m%d")
                for offset in range((business_date - start_date).days + 1)
            ]
        else:
            # 4時間足以上は年ごとに取得するため、CSVの最終日時から開始年を決めます。
            # CSVが空の場合は、現在の基準年だけを取得します。
            start_year = (
                datetime.fromisoformat(rows[-1]["timestamp_jst"]).year
                if rows
                else business_date.year
            )

            # APIへ渡す日付を"2026"のようなYYYY形式で1年ずつ作ります。
            date_keys = [
                str(year) for year in range(start_year, business_date.year + 1)
            ]

        # 作成した日付または年を順番にAPIへ渡し、ローソク足を取得します。
        for date_key in date_keys:
            fetched_rows = fetch_klines(
                strategy.SYMBOL,
                strategy.PRICE_TYPE,
                interval,
                date_key,
            )

            # 既存のローソク足へ、今回取得したローソク足を追加します。
            rows.extend(fetched_rows)

        # 再取得した同じ開始時刻の足を1本にまとめます。
        # 同じ時刻が複数ある場合は、後からAPIで取得した新しい内容を残します。
        unique_rows = {row["timestamp_jst"]: row for row in rows}

        # CSVが古い足から新しい足の順になるよう、開始日時で並べ替えます。
        rows = sorted(unique_rows.values(), key=lambda row: row["timestamp_jst"])

        # 保存先フォルダが存在しない場合は、親フォルダも含めて作成します。
        path.parent.mkdir(parents=True, exist_ok=True)

        # 更新後の全ローソク足でCSVを上書きします。
        with path.open("w", encoding="utf-8", newline="") as file:
            writer = csv.DictWriter(file, fieldnames=CSV_COLUMNS)

            # 1行目へ列名を書き、その後へローソク足をまとめて書き込みます。
            writer.writeheader()
            writer.writerows(rows)

    print("ローソク足CSVの差分更新が完了しました。")


def main():
    # ローソク足の日付判定を日本時間で行うため、現在時刻をJSTで取得します。
    now = datetime.now(JST)

    # 設定されたすべての時間足について、CSVへ最新データを追加します。
    update_candle_csvs(now)
    print("15分足・1時間足・4時間足・日足のCSVを更新しました。")


# このファイルを直接実行したときだけmain()を動かします。
if __name__ == "__main__":
    main()
entry_signal.py
from datetime import datetime, timedelta, timezone

import backtest_config as strategy
import backtest_fx

# バックテストと同じ売買ルールを使い、保存済みのローソク足CSVから
# 「現在、買いシグナルが出ているか」を確認するスクリプトです。
#
# このファイル自身には移動平均線やシグナルの計算処理を重複して書かず、
# backtest_fx.py の関数を呼び出します。これにより、バックテスト時と
# シグナル確認時で判定ルールがずれることを防ぎます。

# -----------------------------------------------------------------------------
# 日時の基本設定
# -----------------------------------------------------------------------------

# GMOコインのローソク足を日本時間で扱うため、UTCより9時間進んだ
# 日本標準時(JST)のタイムゾーンを作ります。
JST = timezone(timedelta(hours=9), name="JST")


def find_latest_signal(now):
    """確定済みの最新15分足で、バックテストと同じ買い条件を判定する。"""

    print("最新の確定足でエントリー条件を判定します。")

    # 設定ファイルに書かれた15分足・1時間足・4時間足・日足を読み込みます。
    # close_timeがnow以下の足だけを残すことで、まだ形成途中の足や
    # 未来の情報を誤ってシグナル判定に使わないようにしています。
    candles = {
        interval: [
            candle
            for candle in backtest_fx.load_candles(interval, path)
            if candle["close_time"] <= now
        ]
        for interval, path in strategy.CANDLE_CSV_PATHS.items()
    }

    # エントリーのタイミングは15分足で判断します。
    entry_candles = candles["15min"]

    # fast_smaは短期の単純移動平均線です。
    backtest_fx.add_sma(
        entry_candles, strategy.ENTRY_FAST_SMA_PERIOD, "fast_sma"
    )

    # slow_smaは長期の単純移動平均線です。
    # 短期SMAが長期SMAを下から上へ抜けると、ゴールデンクロスになります。
    backtest_fx.add_sma(
        entry_candles, strategy.ENTRY_SLOW_SMA_PERIOD, "slow_sma"
    )

    # 1時間足・4時間足・日足について、それぞれの終値とSMAを比較し、
    # 上昇トレンドかどうかを時刻から調べられるようにします。
    trend_lookups = {}
    for interval, period in strategy.TREND_SMA_PERIODS.items():
        # 各上位足へ、設定された期間のSMAをtrend_smaという名前で追加します。
        backtest_fx.add_sma(candles[interval], period, "trend_sma")

        # シグナル時刻より前に確定した最新の上位足を検索するためのデータです。
        trend_lookups[interval] = backtest_fx.make_completed_trend_lookup(
            candles[interval], "trend_sma"
        )

    # リストの末尾が最新足なので、「本数 - 1」を添字にします。
    index = len(entry_candles) - 1

    # ゴールデンクロスの判定には現在足と直前足の2本が必要です。
    if index < 1:
        print("判定に必要な15分足が不足しています。")
        return None

    print(f"判定対象: {entry_candles[index]['close_time'].isoformat()}")

    # backtest_fx.pyと同じ条件で判定します。
    # 条件は「15分足でゴールデンクロス」かつ「すべての上位足が上昇トレンド」です。
    if not backtest_fx.has_long_entry_signal(index, entry_candles, trend_lookups):
        return None

    # 条件を満たした場合は、シグナルが出た最新の15分足を返します。
    return entry_candles[index]


def main():
    """現在時刻でシグナルを判定し、結果を画面へ表示する。"""

    # タイムゾーン情報付きの現在の日本時刻を取得します。
    now = datetime.now(JST)

    # 最新の確定足を使って買いシグナルを探します。
    signal = find_latest_signal(now)

    # find_latest_signal()がNoneを返した場合、買い条件は未成立です。
    if signal is None:
        print("買いシグナルはありません。")
        return

    # シグナルがある場合は、確認しやすいよう日時・終値・SMAを表示します。
    print("買いシグナルがあります。")
    print(f"  シグナル確定日時: {signal['close_time'].isoformat()}")
    print(f"  終値: {signal['close']:.3f}")
    print(f"  短期SMA: {signal['fast_sma']:.3f}")
    print(f"  長期SMA: {signal['slow_sma']:.3f}")


# このファイルを直接実行した場合だけmain()を呼び出します。
# 別のPythonファイルからimportされた場合は、自動では判定を始めません。
if __name__ == "__main__":
    main()
order_recorder.py
import csv

ORDER_COLUMNS = [
    "recorded_at",
    "mode",
    "status",
    "symbol",
    "side",
    "size",
    "entry_price",
    "take_profit_price",
    "stop_loss_price",
    "signal_time",
    "client_order_id",
    "root_order_id",
    "message",
]


def save_order_record(path, record):
    """デモ発注または実注文の内容をCSVへ追記する。"""
    path.parent.mkdir(parents=True, exist_ok=True)
    file_exists = path.exists()
    with path.open("a", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=ORDER_COLUMNS)
        if not file_exists:
            writer.writeheader()
        writer.writerow(record)

backtest_config.pybacktest_fx.pyは、事前準備で作成したものを使用してください。

ステップ3|デモモードの設定を確認する

ファイルを用意したら、live_config.pyの設定を確認します。

まずは、ENABLE_REAL_ORDERFalseになっていることを確認してください。

ENABLE_REAL_ORDER = False

Falseの場合はデモモードで動作するため、GMO FXへ実際の注文は送信されません。

あわせて、以下の設定も確認しておきましょう。

  • 指値注文へ加えるバッファ
  • 最大スプレッド
  • 注文価格の小数桁数
  • 注文履歴CSVの保存先

初めて実行する場合は、必ずデモモードのまま動作を確認してください。

ステップ4|live_trade.pyを実行して結果を確認する

設定が完了したら、PowerShellなどでプロジェクトフォルダへ移動し、以下のコマンドを実行します。

python live_trade.py

live_trade.pyを実行すると、ローソク足の更新、エントリー判定、最新レートの取得、IFDOCO注文の作成が順番に行われます

買いシグナルが出た場合は、ターミナルに以下の内容が表示されます。

  • 通貨ペア
  • 注文数量
  • エントリー価格
  • 利益確定価格
  • 損切り価格

デモ発注の内容は、以下のCSVへ記録されます。

data/orders/order_history.csv

ターミナルの表示とCSVの内容を確認し、ローソク足の更新から注文内容の記録まで正常に動作しているか確認しましょう。

実注文への切り替えは、デモモードで問題なく動作することを確認してから行ってください。

live_trade.pyで実行される各処理を解説

live_trade.pyを実行すると、ローソク足の更新から注文内容の記録までが順番に行われます。

処理の順番は、以下のとおりです。

  • ローソク足データを更新する
  • エントリー条件を判定する
  • 最新レートとスプレッドを確認する
  • IFDOCO注文を作成する
  • デモ発注または実注文を行う
  • 注文内容と送信結果をCSVへ記録する

それぞれの処理を詳しく見ていきましょう。

1.ローソク足データを更新する

最初に、candle_updater.pyupdate_candle_csvs()を呼び出し、作成済みのローソク足CSVを更新します。

print("\n【ローソク足データの更新】")
candle_updater.update_candle_csvs(now)

この処理では、15分足・1時間足・4時間足・日足のCSVへ、不足している最新データを追加します。

詳しい仕組みやcandle_updater.pyのコードは、以下の記事で解説しています。

2.エントリー条件を判定する

ローソク足の更新後は、entry_signal.pyfind_latest_signal()を呼び出します。

print("\n【エントリー条件の判定】")
signal = entry_signal.find_latest_signal(now)

if signal is None:
    print("買いシグナルはありません。")
    return

買いシグナルが出ていない場合は、注文処理へ進まず、その時点で終了します。

詳しい判定方法やentry_signal.pyのコードは、以下の記事で解説しています。

3.最新レートとスプレッドを確認する

買いシグナルが出た場合は、GMO FX APIから最新のASKとBIDを取得します。

print("\n【最新レートの取得】")
ticker = latest_ticker(strategy.SYMBOL)

ask = float(ticker["ask"])
bid = float(ticker["bid"])

print(f"ASK: {format_price(ask)}")
print(f"BID: {format_price(bid)}")

続いて、ASKとBIDの差をpipsへ変換し、スプレッドを確認します。

spread_pips = (ask - bid) / strategy.PIP_SIZE
print(f"スプレッド: {spread_pips:.2f} pips")

if spread_pips > config.MAX_SPREAD_PIPS:
    print(f"スプレッドが広いため見送ります: {spread_pips:.2f} pips")
    return

スプレッドがlive_config.py設定した上限を超えている場合は、注文を見送ります

4.IFDOCO注文を作成する

スプレッドが設定範囲内であれば、最新のASKを基準にIFDOCO注文を作成します。

print("\n【IFDOCO注文の作成】")
order = make_ifd_oco_order(ask, now)
print_order(order)

make_ifd_oco_order()では、エントリー価格、利益確定価格、損切り価格を計算します。

entry_price = ask + config.LIMIT_BUFFER_PIPS * strategy.PIP_SIZE

take_profit_price = (
    entry_price
    + strategy.TAKE_PROFIT_PIPS * strategy.PIP_SIZE
)

stop_loss_price = (
    entry_price
    - strategy.STOP_LOSS_PIPS * strategy.PIP_SIZE
)

計算した価格を使い、GMO FX APIへ送信する注文データを作成します。

order = {
    "symbol": strategy.SYMBOL,
    "clientOrderId": "SMA" + now.strftime("%Y%m%d%H%M%S"),
    "firstSide": "BUY",
    "firstExecutionType": "LIMIT",
    "firstSize": str(strategy.POSITION_SIZE_UNITS),
    "firstPrice": format_price(entry_price),
    "secondSize": str(strategy.POSITION_SIZE_UNITS),
    "secondLimitPrice": format_price(take_profit_price),
    "secondStopPrice": format_price(stop_loss_price),
}

一次注文が約定したあと、利益確定または損切りのどちらかが実行されるIFDOCO注文です。

5.デモ発注または実注文を行う

live_config.pyENABLE_REAL_ORDERによって、デモモードと実注文を切り替えます。

if not config.ENABLE_REAL_ORDER:
    mode = "DEMO"
    status = "DEMO"
    message = "実際の注文は送信していません。"

ENABLE_REAL_ORDERFalseの場合は、GMO FXへ注文を送信しません。

実注文の場合は、既存の建玉と未約定注文を確認します。

positions = list_items(
    private_get(
        "/v1/openPositions",
        {"symbol": strategy.SYMBOL},
    )
)

orders = list_items(
    private_get(
        "/v1/activeOrders",
        {"symbol": strategy.SYMBOL},
    )
)

if positions or orders:
    print("既存の建玉または未約定注文があるため見送ります。")
    return

建玉と未約定注文がなければ、IFDOCO注文を送信します。

try:
    result = private_post("/v1/ifoOrder", order)
    status = "SUBMITTED"
    message = "GMO FXへ注文を送信しました。"
    root_order_id = root_order_id_from_result(result)
except Exception as error:
    status = "ERROR"
    message = str(error)
    order_error = error

注文送信時にエラーが発生した場合も、内容を記録できるように処理しています。

6.注文内容と送信結果をCSVへ記録する

最後に、注文内容と送信結果をCSVへ保存できる形式にまとめます。

record = make_order_record(
    now,
    signal,
    order,
    mode=mode,
    status=status,
    message=message,
    root_order_id=root_order_id,
)

作成したデータは、order_recorder.pysave_order_record()を使って保存します。

save_order_record(
    config.ORDER_HISTORY_PATH,
    record,
)

print(f"保存先: {config.ORDER_HISTORY_PATH}")

保存先は、初期設定では以下のCSVです。

data/orders/order_history.csv

デモモードでは作成した注文内容が記録され、実注文では送信結果や注文IDも記録されます。

なお、今回のスクリプトでは、注文が実際に約定したか、TP・SLで決済されたか、最終的な損益がいくらになったかまでは確認していません。

GMO FX APIで実注文へ切り替える方法

デモモードで正常に動作することを確認できたら、GMO FX APIへ実際のIFDOCO注文を送信できるように設定します。

実注文へ切り替える流れは、以下の3ステップです。

  • GMOコインの外国為替FX口座を開設する
  • 実注文の設定と注文条件を確認する
  • APIキーを環境変数へ設定して実行する

それぞれ順番に確認していきましょう。

1. GMOコインの外国為替FX口座を開設する

引用:GMOコイン HP

GMO FX APIから実際に注文を送信するには、GMOコインの外国為替FX口座が必要です。

まだ口座を持っていない場合は、先にGMOコインの公式サイトから口座開設を済ませておきましょう。

口座を開設しておけば、今回作成したPythonスクリプトを使って、エントリー条件を満たしたタイミングで実際のIFDOCO注文を送信できるようになります。

すでにGMOコインの暗号資産口座を利用している場合も、外国為替FX口座で取引できる状態になっているか確認してください。

口座開設や外国為替FXの利用準備が完了していない場合は、以下の公式サイトから手続きを進めましょう。

\口座開設は無料/

GMOコインの詳細を見てみる

2. 実注文の設定と注文条件を確認する

次に、live_config.pyの実注文設定を変更します。

ENABLE_REAL_ORDER = True

Trueに変更すると、エントリー条件を満たした場合に、GMO FXへ実際のIFDOCO注文が送信されます

切り替える前に、以下の設定を確認してください。

  • 通貨ペア
  • 注文数量
  • 利益確定幅と損切り幅
  • 指値注文へ加えるバッファ
  • 最大スプレッド
  • 1pipsあたりの価格幅
  • 注文価格の小数桁数

特に注文数量やTP・SLの設定を間違えると、想定より大きな損失につながる可能性がありますので注意が必要です。

最初は小さい注文数量で動作を確認しましょう。

3. APIキーを環境変数へ設定して実行する

最後に、GMOコインで作成したAPIキーとAPIシークレットを、PowerShellの環境変数へ設定します。

$env:GMO_FX_API_KEY="実際のAPIキー"
$env:GMO_FX_SECRET_KEY="実際のAPIシークレット"

環境変数を設定したら、同じPowerShell上でlive_trade.pyを実行しましょう。

python live_trade.py

この方法で設定したAPIキーとAPIシークレットは、現在開いているPowerShellを閉じるまで保持されます。

同じPowerShell上であれば、スクリプトを複数回実行しても再設定は不要です。

PowerShellを閉じた場合やPCを再起動した場合は、実行前にもう一度環境変数を設定してください。

まとめ

今回は、GMO FX APIを使って、最新のローソク足データを取得してからエントリー注文を作成するまでの流れを解説しました。

今回実装した主な処理は、以下のとおりです。

  • 作成済みのCSVへ最新のローソク足データを追加する
  • バックテストで使用したコンフィグを使ってエントリーを判定する
  • 最新のASK・BIDとスプレッドを確認する
  • TP・SLを含むIFDOCO注文を作成する
  • デモ発注の内容や実注文の送信結果をCSVへ記録する

各処理を別ファイルに分け、live_trade.pyから順番に呼び出すことで、ローソク足の更新から注文内容の記録までをまとめて実行できるようになりました。

初期設定ではデモモードで動作するため、実際の注文を送信せずに処理の流れを確認できます。

まずはデモモードで正常に動作することを確認し、注文数量やTP・SLなどの設定を見直してから実注文へ切り替えましょう。

ただし、今回のスクリプトだけでは、注文が実際に約定したかどうかや、決済結果、損益までは確認していません。

実際にFX自動売買Botとして運用するには、注文結果の取得や建玉管理、決済結果の記録、Discord通知、自動実行などの処理も必要です。

これらの機能については、今後の記事で順番に解説していきます。

-FX自動売買, Python, 個人開発