English Deutsch
preview
Python-MetaTrader 5ストラテジーテスター(第4回):テスター入門

Python-MetaTrader 5ストラテジーテスター(第4回):テスター入門

MetaTrader 5トレーディングシステム |
19 4
Omega J Msigwa
Omega J Msigwa

内容


はじめに

これまでの記事では、MetaTrader 5風のストラテジーテスターをゼロから構築するための基礎を解説してきました。コアとなる構造はすでに整っていますが、プロジェクトにはまだいくつか重要な要素が不足しています。

現時点では、ティックやバーを順番に処理する仕組みはまだありません。また、未決済注文やシミュレーション上の取引口座を監視する機構も存在していません。また、損益、ドローダウン、勝率、リスクリワード比、詳細な取引統計といったパフォーマンス指標もまだ実装されていません。

本記事では、これらの不足している要素を補い、プロジェクトをさらに改善していきます。。


シミュレーターからテスターへ

これまでの記事で扱ってきたクラスをご覧になっている方はお気づきかと思いますが、クラスは「Simulator」と呼ばれていました。この名前は、すべてのユーザーにとって分かりやすいシンプルなものにするために付けられました。実際、MetaTrader 5のストラテジーテスターもシミュレーターの一種です。そこで今回は、クラス名を「Tester」に変更します。

class Tester:
    def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"):

この変更に伴い、ログのフォルダ構造も変更します。

class Tester:
    def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"):
        """MetaTrader 5-Like Strategy tester for the MetaTrader5-Python module.

        Args:
            simulator_name (str): A Bot or Simulator's name
            mt5_instance (mt5): An instance of the Initialized MetaTrader 5 module
            deposit (float): The initial account balance for the Tester
            leverage (_type_, optional): A leverage of the simulated account. Defaults to "1:100".

        Raises:
            RuntimeError: When one of the operation fails
        """
        
        self.mt5_instance = mt5_instance
        self.simulator_name = simulator_name
        
        config.mt5_logger = config.get_logger(self.simulator_name+".mt5", 
                                                 logfile=os.path.join(config.MT5_LOGS_DIR, f"{config.LOG_DATE}.log"), level=config.logging_level)
        
        config.tester_logger = config.get_logger(self.simulator_name+".tester", 
                                                    logfile=os.path.join(config.TESTER_LOGS_DIR, f"{config.LOG_DATE}.log"), level=config.logging_level)

以下が実行結果です。


テスターの設定と初期化

まず、カスタムストラテジーテスターに必要な要件を正しく理解するために、MetaTrader 5のストラテジーテスターの設定項目の確認が必要です。

項目 説明と使用法
Expert テスト対象とするエキスパートアドバイザー(EA)の名前です。
Symbol EAが適用されるチャートの銘柄です。EAが複数の銘柄を取引する場合でも、この銘柄は「ホスト」として必ず必要になります。
Timeframe(Symbolの横) EAが使用するチャートの時間足です。TimeframeはOnTick()、OnTimer()、およびバーイベントの動作に影響します。
Date(1番目のフィールド) テストに使用する履歴データの開始日です。
Date(2番目のフィールド) テストに使用する履歴データの終了日です。
Forward テストまたは最適化期間の後に行われるフォワードテスト期間です。
Delays  約定遅延のシミュレーションで、以下の2種類があります。
  • ゼロレイテンシ(理想的な約定、完全約定・デフォルト)・
  • カスタム遅延(実際のブローカー遅延を再現)
Modelling テスト中にティックをどのように生成するかを決定します。対応するモデルは以下の通りです。
  • 全ティック
  • 実ティックに基づく全ティック
  • 始値のみ
  • 1分間OHLC
  • 数学的計算
Deposit  初期資金(開始残高)です。 
leverage  テストで使用されるレバレッジです。 
Optimization パラメータ最適化モードです。無効の場合は単一テストが実行され、有効の場合は異なるパラメータ組み合わせで複数回テストが実行されます。 
visual mode...  テスト中にチャートアニメーションを表示するかどうかを指定します。 

同様の設定をTesterクラスにも渡します。コンソールベースのPythonプロジェクトでは、JSONファイルを利用するのが便利です。

configs/tester.json

{
    "tester": {
        "bot_name": "MY EA",
        "symbols": ["EURUSD", "USDCAD", "USDJPY"],
        "timeframe": "H1",
        "start_date": "01.01.2025 00:00",
        "end_date": "31.12.2025 00:00",
        "modelling" : "real_ticks",
        "deposit": 1000,
        "leverage": "1:100"
    }
}

現時点では、必要なパラメータは2、3だけで十分です。プロジェクトをさらに深く進めていくにつれて、まだ改善の余地があります。

設定用にJSONファイルを導入したことで、以前のバージョンのクラスで使用していた一部の引数は不要となりました。以下に、新しいクラスのコンストラクタを示します。

class Tester:
    def __init__(self, tester_config: dict, mt5_instance: mt5):
        """MetaTrader 5-Like Strategy tester for the MetaTrader5-Python module.

        Args:
            configs_json (dict): a dictonary containing tester configurations
        Raises:
            RuntimeError: When one of the operation fails
        """

MetaTrader 5では、ホストとなる銘柄のみを指定すればよく、プラットフォーム側がインポート処理やプログラム内で使用されるすべての銘柄に対するティックの管理を自動的におこないます。

仕組みの内部実装は公開されていませんが、MQL5はPythonとは異なりコンパイル型言語であるため、同様の挙動を再現するのは簡単ではありません。現時点では、プログラムの実行時に使用するすべての銘柄をユーザーが明示的に指定する必要があります。 

"symbols": ["EURUSD", "USDCAD", "USDJPY"],

しかし、パラメータをJSONファイルに依存する方法には、エラーが発生しやすいという問題があります。わずかなタイポであってもプログラム全体が動作しなくなる可能性があります。 

そのため、このようなオブジェクトから受け取った情報を検証する関数が必要です。

 I:JSONキーの妥当性チェック

以下の関数は、辞書に不足しているパラメータや未知の(余分な)パラメータが存在する場合に、ランタイムエラーを発生させます。

validators.pyの内部

class TesterConfigValidators:
    """
    Responsible for validating and normalizing strategy tester configurations.
    """

    def __init__(self):
        pass
    
    @staticmethod
    def _validate_keys(raw_config: Dict) -> None:
        required_keys = {
            "bot_name",
            "symbols",
            "timeframe",
            "start_date",
            "end_date",
            "modelling",
            "deposit",
            "leverage",
        }

        provided_keys = set(raw_config.keys())

        missing = required_keys - provided_keys
        if missing:
            raise RuntimeError(f"Missing tester config keys: {missing}")

        extra = provided_keys - required_keys
        if extra:
            raise RuntimeError(f"Unknown tester config keys: {extra}")

また、JSONファイルに新しいパラメータを追加し、testerという親キーの下に項目を増やすたびに、required_keysという辞書も更新する必要があります。

II:各キーが正しい値の型を持っているかの確認

各エントリに対して、正しいデータ型が指定されている必要があります。

validators.py

    @staticmethod
    def parse_tester_configs(raw_config: Dict) -> Dict:
        TesterConfigValidators._validate_keys(raw_config)

        cfg: Dict = {}

        # --- BOT NAME ---
        cfg["bot_name"] = str(raw_config["bot_name"])

        # --- SYMBOLS ---
        symbols = raw_config["symbols"]
        if not isinstance(symbols, list) or not symbols:
            raise RuntimeError("symbols must be a non-empty list")
        cfg["symbols"] = symbols

        # --- TIMEFRAME ---
        timeframe = raw_config["timeframe"].upper()
        if timeframe not in utils.TIMEFRAMES:
            raise RuntimeError(f"Invalid timeframe: {timeframe}")
        cfg["timeframe"] = timeframe

        # --- MODELLING ---
        modelling = raw_config["modelling"].lower()
        VALID_MODELLING = {"real_ticks", "new_bar"}
        if modelling not in VALID_MODELLING:
            raise RuntimeError(f"Invalid modelling mode: {modelling}")
        cfg["modelling"] = modelling

        # --- DATE PARSING ---
        try:
            start_date = datetime.strptime(
                raw_config["start_date"], "%d.%m.%Y %H:%M"
            )
            end_date = datetime.strptime(
                raw_config["end_date"], "%d.%m.%Y %H:%M"
            )
        except ValueError:
            raise RuntimeError("Date format must be: DD.MM.YYYY HH:MM")

        if start_date >= end_date:
            raise RuntimeError("start_date must be earlier than end_date")

        cfg["start_date"] = start_date
        cfg["end_date"] = end_date

        # --- DEPOSIT ---
        deposit = float(raw_config["deposit"])
        if deposit <= 0:
            raise RuntimeError("deposit must be > 0")
        cfg["deposit"] = deposit

        # --- LEVERAGE ---
        cfg["leverage"] = TesterConfigValidators._parse_leverage(raw_config["leverage"])

        return cfg

クラスのコンストラクタ内では、JSONファイルから受け取った辞書を検証し、その結果の辞書をクラス全体からアクセス可能な変数に格納する前に処理します。

self.tester_config = TesterConfigValidators.parse_tester_configs(tester_config)

これでJSONファイルから情報を取得できるようになったので、次は自動売買ロボットのテストにおいて、異なるモデリングをどのように扱うかを確認します。


実ティックに基づくストラテジーテスト

MetaTrader 5のストラテジーテスターで利用可能な価格モデリングの中で、これが最も正確な方法です。ブローカーから直接取得されたティックデータを用いて、プログラム(インジケーターまたはEA)をテストします。

実装は非常にシンプルです。すでに、特定の履歴期間からティックおよびバーを取得する関数を導入しているためです。

tester.py

from src import ticks
class Tester:
    def __init__(self, tester_config: dict, mt5_instance: mt5):
        
        # ...
        
        self.__GetLogger().info("Tester Initializing")
        self.__GetLogger().info(f"Tester configs: {self.tester_config}")
        
        self.TESTER_ALL_TICKS_INFO = [] # for storing all ticks to be used during the test
        self.TESTER_ALL_BARS_INFO = [] # for storing all bars to be used during the test
        
        start_dt = self.tester_config["start_date"]
        end_dt = self.tester_config["end_date"]
        
        modelling = self.tester_config["modelling"]
        
        for symbol in self.tester_config["symbols"]:
            
            if modelling == "real_ticks":
                    
                ticks_obtained = ticks.fetch_historical_ticks(start_datetime=start_dt, end_datetime=end_dt, symbol=symbol)
                
                ticks_info = {
                    "symbol": symbol,
                    "ticks": ticks_obtained,
                    "size": ticks_obtained.height,
                    "counter": 0
                }
                
                self.TESTER_ALL_TICKS_INFO.append(ticks_info)

すべての指定された銘柄からティックデータを取得した後、その結果をTESTER_ALL_TICKS_INFOという配列に追加します。  これは後にメインのシミュレーションループで走査するために使用します。

このクラス内のOnTickという関数の中で、これまでのすべての処理を一つに統合します。

    def OnTick(self, ontick_func):
        """Calls the assigned function upon the receival of new tick(s)

        Args:
            ontick_func (_type_): A function to be called on every tick
        """
        
        if not self.IS_TESTER:
            return

        modelling = self.tester_config["modelling"]
        if modelling == "real_ticks":

            self.__GetLogger().debug(f"total number of ticks: {total_ticks}")

                while True:
                    
                    any_tick_processed = False

                    for ticks_info in self.TESTER_ALL_TICKS_INFO:

                        symbol = ticks_info["symbol"]
                        size = ticks_info["size"]
                        counter = ticks_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = ticks_info["ticks"].row(counter)

                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        ticks_info["counter"] = counter + 1
                        any_tick_processed = True

                    if not any_tick_processed:
                        break

 このような状況では、プログレスバーが便利です。

    def OnTick(self, ontick_func):
        """Calls the assigned function upon the receival of new tick(s)

        Args:
            ontick_func (_type_): A function to be called on every tick
        """
        
        if not self.IS_TESTER:
            return

        modelling = self.tester_config["modelling"]
        if modelling == "real_ticks" or modelling == "every_tick":
            
            total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO)

            self.__GetLogger().debug(f"total number of ticks: {total_ticks}")

            with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar:
                while True:
                    
                    any_tick_processed = False

                    for ticks_info in self.TESTER_ALL_TICKS_INFO:

                        symbol = ticks_info["symbol"]
                        size = ticks_info["size"]
                        counter = ticks_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = ticks_info["ticks"].row(counter)

                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        ticks_info["counter"] = counter + 1
                        any_tick_processed = True

                        pbar.update(1)

                    if not any_tick_processed:
                        break

上記のOnTickメソッドは、与えられた関数を受け取ります。これはMQL5におけるOnTick関数と同様に、取引処理のメイン関数として使用される想定です。

引数として受け取ったontick_funcは、メインのシミュレーションループ内でティックが進むたびに、OnTickメソッドの中で毎回呼び出されます。

使用例:

tester.py

if __name__ == "__main__":
    
    mt5.initialize()
    
    try:
        with open(os.path.join('configs/tester.json'), 'r', encoding='utf-8') as file: # reading a JSON file
            # Deserialize the file data into a Python object
            data = json.load(file)
    except Exception as e:
        raise RuntimeError(e)

    sim = Tester(tester_config=data["tester"], mt5_instance=mt5)

    def ontick_function():
        pass
        # print("some trading actions")

    sim.OnTick(ontick_function)

以下が実行結果です。


合成ティックに基づくストラテジーテスト

MetaTrader 5のストラテジーテスターでモデリング設定を[全ティック]にすると、ターミナルは合成ティックを使用します。これは本記事で解説されているような何らかのアルゴリズムによって生成されたものです。

このアルゴリズムを模倣しようとした実装は、src/ticks_gen.pyにあるクラスの中に見つけることができます。

今回は、Polars Dataframeからティックを読み込むのではなく、1分足に基づいてティックを生成する方式を採用します。

tester.pyの内部

from src.ticks_gen import TicksGen
elif modelling == "every_tick":
                
    bars_df = bars.fetch_historical_bars(symbol=symbol, 
                                        timeframe=utils.TIMEFRAMES["M1"], 
                                        start_datetime=start_dt, end_datetime=end_dt)
                
    ticks_obtained = TicksGen.generate_ticks_from_bars(bars=bars_df, symbol=symbol, 
                                                    symbol_point=self.symbol_info(symbol).point,
                                                    out_dir=f"{config.SIMULATED_TICKS_DIR}/{symbol}", 
                                                    return_df=True)
                
    ticks_info = {
        "symbol": symbol,
        "ticks": ticks_obtained,
        "size": ticks_obtained.height,
        "counter": 0
    }
                
    self.TESTER_ALL_TICKS_INFO.append(ticks_info)

実ティックと合成ティックの唯一の違いは、一方がデータベースから抽出されたデータであり、もう一方が生成されたデータであるという点だけです。それ以外のすべては同じであるため、OnTickメソッドの中では両者を同様に扱います。 

    def OnTick(self, ontick_func):
        """Calls the assigned function upon the receival of new tick(s)

        Args:
            ontick_func (_type_): A function to be called on every tick
        """
        
        if not self.IS_TESTER:
            return

        modelling = self.tester_config["modelling"]
        if modelling == "real_ticks" or modelling == "every_tick":
            
            total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO)

            self.__GetLogger().debug(f"total number of ticks: {total_ticks}")

            with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar:
                while True:
                    
                    any_tick_processed = False

                    for ticks_info in self.TESTER_ALL_TICKS_INFO:

                        symbol = ticks_info["symbol"]
                        size = ticks_info["size"]
                        counter = ticks_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = ticks_info["ticks"].row(counter)

                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        ticks_info["counter"] = counter + 1
                        any_tick_processed = True

                        pbar.update(1)

                    if not any_tick_processed:
                        break

以下は、設定JSONファイル内でモデリングをevery_tickに設定してクラスTesterを実行した結果です。

Tester Progress: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 20613922/20613922 [01:31<00:00, 224889.74tick/s]

悪くありません。3つの銘柄で1年間全体をシミュレーションするのに、約1分30秒かかりました。


新しいバーに基づくストラテジーテスト

これはMetaTrader 5のストラテジーテスターにおいて最も高速かつ最も精度の低いモデリングモードです。このモードでは、新しいバーが形成されたタイミングのみでプログラムのテストがおこなわれます。つまり、バーの始値から終値までの間に発生するすべてのティックはスキップされます。

クラスのコンストラクタ内では、実際のティックをシミュレーション用に準備したときと同じ原則に従います。

今回は、すべての銘柄から収集したバーをTESTER_ALL_BARS_INFOという配列に格納します。

tester.py

        self.TESTER_ALL_BARS_INFO = [] # for storing all bars to be used during the test
        
        for symbol in self.tester_config["symbols"]:
            
            if modelling == "real_ticks":
                    
                # ....
            
            elif modelling == "new_bar":
                
                bars_obtained = bars.fetch_historical_bars(symbol=symbol, 
                                                        timeframe=utils.TIMEFRAMES[self.tester_config["timeframe"]],
                                                        start_datetime=start_dt,
                                                        end_datetime=end_dt)
                
                bars_info = {
                    "symbol": symbol,
                    "bars": bars_obtained,
                    "size": bars_obtained.height,
                    "counter": 0
                }
                
                self.TESTER_ALL_BARS_INFO.append(bars_info)

OnTick関数内では、収集したすべてのバーをループ処理します。

    def OnTick(self, ontick_func):
        
        #....
                    
        elif modelling == "new_bar":
            
            bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO]
            total_bars = sum(bars_)
            
            self.__GetLogger().debug(f"total number of bars: {total_bars}")

            with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar:
                while True:
                    
                    self.__account_monitoring()
                    self.__positions_monitoring()
                    self.__pending_orders_monitoring()
                    
                    any_bar_processed = False

                    for bars_info in self.TESTER_ALL_BARS_INFO:

                        symbol = bars_info["symbol"]
                        size = bars_info["size"]
                        counter = bars_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter))
                        
                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        bars_info["counter"] = counter + 1
                        any_bar_processed = True

                        pbar.update(1)

                    if not any_bar_processed:
                        break

しかし、私たちのTesterクラスはティックデータに大きく依存しています。バーはティックとは異なるため、以下の関数を用いてバーの開始時点でティックを生成します。

    def _bar_to_tick(self, symbol, bar):
        """
            Creates a synthetic tick from a bar (MT5-style).
            Uses OPEN price.
        """

        price = bar["open"] if isinstance(bar, dict) else bar[1]
        time = bar["time"] if isinstance(bar, dict) else bar[0]
        spread = bar["spread"] if isinstance(bar, dict) else bar[6]
        tv = bar["tick_volume"] if isinstance(bar, dict) else bar[5]
        
        return {
            "time": time,
            "bid": price,
            "ask": price + spread * self.symbol_info(symbol).point,
            "last": price,
            "volume": tv,
            "time_msc": time.timestamp(),
            "flags": 0,
            "volume_real": 0,
        }

現在の始値をBid価格として扱います。

Ask価格は、現在の始値にそのバーにおけるスプレッド(ポイント単位)を加算した値として仮定します。

以下は、モデリングをnew_barに設定してクラスを実行した際の結果です。

Tester Progress: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████| 18579/18579 [00:00<00:00, 158324.66bar/s]

テストは瞬時に完了しました。これはnew_barモデリングを使用した場合に想定される挙動です。


1分足OHLCに基づくストラテジーテスト

このモデリングタイプは、MetaTrader 5でプログラムをテストする際に、精度と速度のバランスを適切に取ることを目的としています。

このモードに設定すると、ターミナルは1分足チャートのバーを使用し、バーの開始時点でティックを生成します。これは、先ほど新しいバーモードでティックを生成した方法と同様の仕組みです。

バーの取得方法自体は先ほどのモデリングと同じですが、唯一の違いはtimeframe引数です。

elif modelling == "1-minute-ohlc":
                
    bars_obtained = bars.fetch_historical_bars(symbol=symbol, 
                                            timeframe=utils.TIMEFRAMES["M1"],
                                            start_datetime=start_dt,
                                            end_datetime=end_dt)
                
    bars_info = {
        "symbol": symbol,
        "bars": bars_obtained,
        "size": bars_obtained.height,
        "counter": 0
    }
                
    self.TESTER_ALL_BARS_INFO.append(bars_info)

繰り返しになりますが、この2つのモデリングモードは非常に似ているため、new_barモデリングで使用したものと同じループを使用します。

        elif modelling == "new_bar" or modelling == "1-minute-ohlc":
            
            bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO]
            total_bars = sum(bars_)
            
            self.__GetLogger().debug(f"total number of bars: {total_bars}")

            with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar:
                while True:
                    
                    any_bar_processed = False

                    for bars_info in self.TESTER_ALL_BARS_INFO:

                        symbol = bars_info["symbol"]
                        size = bars_info["size"]
                        counter = bars_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter))
                        
                        # Getting ticks at the current bar
                        
                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        bars_info["counter"] = counter + 1
                        any_bar_processed = True

                        pbar.update(1)

                    if not any_bar_processed:
                        break

configs/tester.json

{
    "tester": {
        "bot_name": "MY EA",
        "symbols": ["EURUSD", "USDCAD", "USDJPY"],
        "timeframe": "H1",
        "start_date": "01.01.2025 00:00",
        "end_date": "31.12.2025 00:00",
        "modelling" : "1-minute-ohlc",
        "deposit": 1000,
        "leverage": "1:100"
    }
}

クラスを実行した結果は以下のとおりです。

2026-01-11 17:59:45,462 | DEBUG    | MY EA.tester | [tester.py:1940 -     OnTick() ] => total number of bars: 1113189
Tester Progress: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████| 1113189/1113189 [00:07<00:00, 143610.20bar/s]

これは、new_barモデリング手法に次いで2番目に速い方法となりました。 


OnTick関数内での統合処理

これまでの記事では、口座、指値注文、ポジションを監視するためのさまざまな関数を実装してきました。しかし、それらを実際にテストする手段はまだありませんでした。今回は、それらの関数をクラス内のOnTickメソッドの中で呼び出し、実際の動作に組み込みます。

    def OnTick(self, ontick_func):
        """Calls the assigned function upon the receival of new tick(s)

        Args:
            ontick_func (_type_): A function to be called on every tick
        """
        
        if not self.IS_TESTER:
            return

        modelling = self.tester_config["modelling"]
        if modelling == "real_ticks" or modelling == "every_tick":
            
            total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO)

            self.__GetLogger().debug(f"total number of ticks: {total_ticks}")

            with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar:
                while True:
                    
                    self.__account_monitoring()
                    self.__positions_monitoring()
                    self.__pending_orders_monitoring()
                    
                    any_tick_processed = False

                    for ticks_info in self.TESTER_ALL_TICKS_INFO:

                        symbol = ticks_info["symbol"]
                        size = ticks_info["size"]
                        counter = ticks_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = ticks_info["ticks"].row(counter)

                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        ticks_info["counter"] = counter + 1
                        any_tick_processed = True

                        pbar.update(1)

                    if not any_tick_processed:
                        break
                    
        elif modelling == "new_bar" or modelling == "1-minute-ohlc":
            
            bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO]
            total_bars = sum(bars_)
            
            self.__GetLogger().debug(f"total number of bars: {total_bars}")

            with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar:
                while True:
                    
                    self.__account_monitoring()
                    self.__positions_monitoring()
                    self.__pending_orders_monitoring()
                    
                    any_bar_processed = False

                    for bars_info in self.TESTER_ALL_BARS_INFO:

                        symbol = bars_info["symbol"]
                        size = bars_info["size"]
                        counter = bars_info["counter"]

                        if counter >= size:
                            continue

                        current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter))
                        
                        # Getting ticks at the current bar
                        
                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        bars_info["counter"] = counter + 1
                        any_bar_processed = True

                        pbar.update(1)

                    if not any_bar_processed:
                        break

新しいバーモード(1分足OHLCと新しいバー)では、これらの関数は各バーの開始時に呼び出します。一方で、ティックベースのモードでは、各ティックごとにこれらの関数を呼び出します。


ストラテジーテスターにおける実際の取引処理

それでは、このシミュレーターを使って最初の自動売買ロボットを作成し、その動作を確認してみましょう。

まず最初に、必要なMetaTrader 5ターミナルを初期化します。これは、このプロジェクトで使用する他のPythonモジュールと一緒に、MetaTrader 5モジュールをインポートした直後におこないます。

example_bot.py

import MetaTrader5 as mt5
from tester import Tester
from Trade.Trade import CTrade
import json
import os
import config

if not mt5.initialize(): # Initialize MetaTrader5 instance
    print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}")
    mt5.shutdown()
    quit()

次に、configs/tester.jsonファイルからテスターの設定を読み込みます。

try:
    with open(os.path.join(config.CONFIGS_DIR,'tester.json'), 'r', encoding='utf-8') as file: # reading a JSON file
        # Deserialize the file data into a Python object
        tester_configs = json.load(file)
except Exception as e:
    raise RuntimeError(e)

  Testerクラスを初期化し、設定と初期化済みのMetaTrader 5インスタンスを与えます。

tester = Tester(tester_config=tester_configs["tester"], mt5_instance=mt5) # very important

MetaTrader 5プログラムでよく見かける入力として機能するグローバル変数が必要です。

symbol = "EURUSD"
timeframe = "PERIOD_H1"
magic_number = 10012026
slippage = 100
sl = 500
tp = 700

必要に応じて、CTradeクラスをインスタンス化することで、作業を大幅に簡素化できます。

m_trade = CTrade(simulator=tester, magic_number=magic_number, filling_type_symbol=symbol, deviation_points=slippage)

また、特定の銘柄に関する情報も必要となります。

symbol_info = tester.symbol_info(symbol=symbol)

すべての自動売買ロボットには戦略が必要です。それを定義してみましょう。

ポジションがまだ存在しない場合には新規に1つエントリーし、それがストップロスまたはテイクプロフィットに到達してクローズされるまで保持します。その後、同じプロセスを繰り返します。

このようなロジックを実装するためには、まず特定の属性(ポジションタイプおよびマジックナンバー)を持つポジションが既に存在しているかどうかを確認する関数が必要になります。

def pos_exists(magic: int, type: int) -> bool:

    for position in tester.positions_get():
        if position.type == type and position.magic == magic:
            return True
    
    return False

  この美しい戦略を実行するためのメイン関数を作成します。

def on_tick():
    
    tick_info = tester.symbol_info_tick(symbol=symbol)
    
    ask = tick_info.ask
    bid = tick_info.bid
    
    pts = symbol_info.point
    
    if not pos_exists(magic=magic_number, type=mt5.POSITION_TYPE_BUY): # If a position of such kind doesn't exist
        m_trade.buy(volume=0.1, symbol=symbol, price=ask, sl=ask-sl*pts, tp=ask+tp*pts, comment="Tester buy") # we open a buy position
    
    if not pos_exists(magic=magic_number, type=mt5.POSITION_TYPE_SELL): # If a position of such kind doesn't exist
        m_trade.sell(volume=0.1, symbol=symbol, price=bid, sl=bid+sl*pts, tp=bid-tp*pts, comment="Tester sell") # we open a sell position

プログラムの最後に、この関数をTesterクラスのOnTickメソッドに渡します。

tester.OnTick(ontick_func=on_tick) # very important!

最後に、ストラテジーテスターを実行します。

注意深く確認したところ、いくつかの取引操作(ポジションの新規建ておよび決済)を見つけることができました。

2026-01-11 20:03:42,943 | INFO     | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665468402118449 opened!
Tester Progress:  79%|██████████████████▏    | 882118/1113189 [00:19<00:05, 46032.14bar/s]2026-01-11 20:03:43,349 | INFO     | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665468402118449 closed!
2026-01-11 20:03:43,351 | INFO     | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665494484307258 opened!
2026-01-11 20:03:43,353 | INFO     | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665462461689618 closed!
2026-01-11 20:03:43,353 | INFO     | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665494609542402 opened!
Tester Progress:  80%|██████████████████▎    | 886723/1113189 [00:19<00:04, 45496.53bar/s]2026-01-11 20:03:43,452 | INFO     | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665494609542402 closed!
2026-01-11 20:03:43,453 | INFO     | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665501001632037 opened!
2026-01-11 20:03:43,473 | INFO     | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665494484307258 closed!
2026-01-11 20:03:43,474 | INFO     | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665502337760048 opened!
Tester Progress:  80%|██████████████████▍    | 891275/1113189 [00:19<00:04, 45088.52bar/s]2026-01-11 20:03:43,501 | INFO     | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665502337760048 closed!

見た限りすべて正常に動作しているようです。では、これをさらに詳しく分析していきましょう。


ストラテジーテストレポートの生成

ストラテジーテストが正常に完了すると、MetaTrader 5ターミナルで「ストラテジーテスターレポート」が生成されます。これは、ストラテジーテスト中に実行されたすべての取引操作に基づく統計メトリクスのレポートです。

これらのメトリクスには、総純利益、総利益/損失、プログラムの勝率(ロングおよびショート取引それぞれ)、その他さまざまな統計情報が含まれます。 

MetaTrader 5ターミナル内で確認できるバックテストレポートとは別に、私たちが特に興味を持っているのは、このレポートから抽出できるHTML形式のレポートです。

基本的なレポートテンプレートから始めて、カスタムストラテジーテスターでも同様のレポートを生成してみましょう。

Reports/template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Strategy Tester</title>

    <!-- Bootstrap 5 -->
    <link
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
        rel="stylesheet"
    >
    
    <style>
        body {
            background: #f8f9fa;
        }

        h4 {
            font-size: 14px;
            text-align: center;
            margin: 16px 0 10px;
            font-weight: 600;
        }

        .tester-container {
            max-width: 1200px;
            margin: auto;
        }

        .table-wrapper {
            margin: 0 10%; /* 10% space left and right */
        }

        .table {
            font-size: 10px;
            width: 100%; /* fill the wrapper */
            background: white;
        }

        .table th {
            white-space: nowrap;
            text-align: center;
            font-size: 10px;
        }

        .table td {
            white-space: nowrap;
            font-size: 10px;
        }
    </style>

</head>
<body>

<h4 class="mt-4 text-center">Orders</h4>
<div class="table-wrapper">
    <div class="table-responsive">
        <table class="table table-sm table-striped table-bordered align-middle">
            <thead class="table-light text-center">
                <tr>
                    <th>Open Time</th>
                    <th>Order</th>
                    <th>Symbol</th>
                    <th>Type</th>
                    <th class="text-end">Volume</th>
                    <th class="text-end">Price</th>
                    <th class="text-end">S / L</th>
                    <th class="text-end">T / P</th>
                    <th>Time</th>
                    <th>State</th>
                    <th>Comment</th>
                </tr>
            </thead>
            <tbody>
                {{ORDER_ROWS}}
            </tbody>
        </table>
    </div>
</div>

<h4 class="mb-3 text-center">Deals</h4>
<div class="table-wrapper">
    <div class="table-responsive">
        <table class="table table-sm table-striped table-bordered align-middle">
            <thead class="table-light text-center">
                <tr>
                    <th>Time</th>
                    <th>Deal</th>
                    <th>Symbol</th>
                    <th>Type</th>
                    <th>Entry</th>
                    <th>Volume</th>
                    <th>Price</th>
                    <th>Commission</th>
                    <th>Swap</th>
                    <th>Profit</th>
                    <th>Comment</th>
                    <th>Balance</th>
                </tr>
            </thead>
            <tbody>
                {{DEAL_ROWS}}
            </tbody>
        </table>
    </div>
</div>

</body>
</html>

I:レポートへの注文履歴の書き込み 

このレポートの中で最もシンプルな部分から始めます。それは、シミュレーション中に実行または発生したすべての注文を表示することです。

これらの注文を順番に表示するために、クラス内の「__orders_history_container__」配列を参照します。

この配列は、order_sendメソッド内で注文が発行されたり、ポジションが新規に開かれたり決済されたりするたびに更新(追加)されます。

tester.py

    def __GenerateTesterReport(self, output_file="Tester report.html"):
        
        def render_order_rows(orders):
            rows = []

            for o in orders:
                rows.append(f"""
                <tr>
                    <td>{datetime.fromtimestamp(o.time_setup)}</td>
                    <td>{o.ticket}</td>
                    <td>{o.symbol}</td>
                    <td>{utils.ORDER_TYPE_MAP.get(o.type, o.type)}</td>
                    <td class="text-end">{o.volume_initial:.2f} / {o.volume_current:.2f}</td>
                    <td class="text-end">{o.price_open:.5f}</td>
                    <td class="text-end">{"" if o.sl == 0 else f"{o.sl:.5f}"}</td>
                    <td class="text-end">{"" if o.tp == 0 else f"{o.tp:.5f}"}</td>
                    <td>{datetime.fromtimestamp(o.time_done) if o.time_done else ""}</td>
                    <td>{utils.ORDER_STATE_MAP.get(o.state, o.state)}</td>
                    <td>{o.comment}</td>
                </tr>
                """)

            return "\n".join(rows)


        with open("Reports/template.html", "r", encoding="utf-8") as f:
            template = f.read()

        order_rows_html = render_order_rows(self.__orders_history_container__)
        
        # we populate table's body

        html = (
            template
            .replace("{{ORDER_ROWS}}", order_rows_html)
        )

        with open(output_file, "w", encoding="utf-8") as f:
            f.write(html)

        print(f"Deals report saved to: {output_file}")

出力:

II:レポートへの約定の書き込み

シミュレーション中に発生したすべての約定はクラス内の「__deals_history_container__」配列に保存されます。この配列から必要な情報を抽出し、注文履歴の場合と同様の方法で処理をおこない、それらのデータをレポート用のテンプレートに反映させていきます。

tester.py

    def __GenerateTesterReport(self, output_file="Tester report.html"):
        
        def render_order_rows(orders):
            rows = []

            for o in orders:
                rows.append(f"""
                <tr>
                    <td>{datetime.fromtimestamp(o.time_setup)}</td>
                    <td>{o.ticket}</td>
                    <td>{o.symbol}</td>
                    <td>{utils.ORDER_TYPE_MAP.get(o.type, o.type)}</td>
                    <td class="text-end">{o.volume_initial:.2f} / {o.volume_current:.2f}</td>
                    <td class="text-end">{o.price_open:.5f}</td>
                    <td class="text-end">{"" if o.sl == 0 else f"{o.sl:.5f}"}</td>
                    <td class="text-end">{"" if o.tp == 0 else f"{o.tp:.5f}"}</td>
                    <td>{datetime.fromtimestamp(o.time_done) if o.time_done else ""}</td>
                    <td>{utils.ORDER_STATE_MAP.get(o.state, o.state)}</td>
                    <td>{o.comment}</td>
                </tr>
                """)

            return "\n".join(rows)

        def render_deal_rows(deals):
            rows = []

            for d in deals:

                rows.append(f"""
                <tr>
                    <td>{datetime.fromtimestamp(d.time)}</td>
                    <td>{d.ticket}</td>
                    <td>{d.symbol}</td>
                    <td>{utils.DEAL_TYPE_MAP[d.type]}</td>
                    <td>{utils.DEAL_ENTRY_MAP[d.entry]}</td>
                    <td class="text-end">{d.volume:.2f}</td>
                    <td class="text-end">{d.price:.5f}</td>
                    <td class="text-end">{d.commission:.2f}</td>
                    <td class="text-end">{d.swap:.2f}</td>
                    <td class="text-end">{d.profit:.2f}</td>
                    <td>{d.comment}</td>
                    <td>{round(d.balance, 2)}</td>
                </tr>
                """)

            return "\n".join(rows)


        with open("Reports/template.html", "r", encoding="utf-8") as f:
            template = f.read()

        order_rows_html = render_order_rows(self.__orders_history_container__)
        deal_rows_html = render_deal_rows(self.__deals_history_container__)
        
        # we populate table's body
        
        html = (
            template
            .replace("{{ORDER_ROWS}}", order_rows_html)
            .replace("{{DEAL_ROWS}}", deal_rows_html)
        )

        with open(output_file, "w", encoding="utf-8") as f:
            f.write(html)

        print(f"Deals report saved to: {output_file}")

MetaTrader 5のレポートにおける約定の書き出しに注目すると、最初の約定がbalance(残高)タイプとして記録されていることがわかります。これは、初期入金を示すものです。以下は、同じ結果を実現するための方法です。

    def __make_balance_deal(self, time: datetime) -> namedtuple:

        time_sec = int(time.timestamp())
        time_msc = int(time.timestamp() * 1000)

        return self.TradeDeal(
            ticket=self.__generate_deal_ticket(),
            order=0,
            time=time_sec,
            time_msc=time_msc,
            type=self.mt5_instance.DEAL_TYPE_BALANCE,
            entry=self.mt5_instance.DEAL_ENTRY_IN,
            magic=0,
            position_id=0,
            reason=np.nan,
            volume=np.nan,
            price=np.nan,
            commission=0.0,
            swap=0.0,
            profit=0.0,
            fee=0.0,
            symbol="",
            balance=self.AccountInfo.balance,
            comment="",
            external_id=""
        )
シミュレーションの開始時に、balanceの約定を作成し、それを「__deals_history_container__」配列の先頭に追加します。これにより、この約定が履歴内の最初のエントリとなります。
    def __TesterInit(self):
        
        self.__deals_history_container__.append(
            self.__make_balance_deal(time=self.tester_config["start_date"])
        )
        
    def __TesterDeinit(self):
        
        # generate a report at the end
        self.__GenerateTesterReport(output_file=f"Reports/{self.tester_config['bot_name']}-report.html")

注意点として、テスターのレポート生成関数は「__TesterDeinit」関数の中で呼び出します。この関数はテスターのシミュレーション終了時に実行されることを想定しており、レポートに関するすべての処理(計算、分析、生成、および保存)はこのメソッド内でまとめて実行されます。

 以下が実行結果です。

III:テスター統計情報の書き込み

MetaTrader 5ターミナルが提供する統計指標を再現するには、それぞれの指標が何を意味するのかを理解し、それをもとにレポート用に手動で計算します。

指標 説明 計算(MT5)
History Quality 使用された履歴データの品質 (モデル化されたティック数 ÷ 必要ティック数) × 100%
Bars 処理されたバーの数 N(テスト期間中のバー)
Ticks 使用された価格ティックの数 N(処理されたティックイベント)
Symbols テストに関与した銘柄数 N(取引された銘柄)
Total Net Profit 最終的な損益結果 Σ(全取引損益)
Gross Profit 勝ち取引の総利益 Σ(損益_i | 損益_i > 0)
Gross Loss 損失取引の総損失 Σ(損益_i | 損益_i < 0)
Profit Factor 収益性の比率 Σ(損益_i > 0) ÷ |Σ(損益_i < 0)|
Expected Payoff 取引あたりの平均利益 Σ(損益) ÷ N(取引)
Recovery Factor ドローダウンからの回復力 Σ(損益) ÷ 最大ドローダウン
Sharpe Ratio リスク調整後リターン 平均(収益) ÷ 標準偏差(収益)
Z-Score 取引の連続性のランダム性 Z検定(勝敗系列)
AHPR 算術的保有期間リターン
GHPR 幾何的保有期間リターン
GHPR=(BalanceClose/BalanceOpen)^(1/N)
LR Correlation エクイティ曲線のトレンド強度 corr(取引インデックス, エクイティ)
LR Standard Error エクイティトレンドからの誤差 std(回帰残差)
Margin Level 口座の安全度 (エクイティ ÷ 証拠金) × 100%
Total Trades 決済済みポジション数 N(決済取引)
Total Deals すべての約定数 N(部分決済を含んだ約定)
Short Trades (won %) 売り取引勝率 N(勝ちショート) ÷ N(ショート) × 100
Long Trades (won %) 買い取引勝率 N(勝ちロング) ÷ N(ロング) × 100
Profit Trades (%) 勝ち取引比率 N(勝ち取引) ÷ N(取引) × 100
Loss Trades (%) 負け取引比率 N(負け取引) ÷ N(取引) × 100
Largest Profit Trade 最大利益取引 max(損益_i)
Largest Loss Trade 最大損失取引 min(損益_i)
Average Profit Trade 平均利益取引 Σ(損益_i | 損益_i > 0) ÷ N(勝ち取引)
Average Loss Trade 平均損失取引 Σ(損益_i | 損益_i < 0) ÷ N(負け取引)
Maximum Consecutive Wins 最大連勝数 max(連勝長)
Maximum Consecutive Losses 最大連負数 max(連負長)
Maximal Consecutive Profit 最大連勝利益 max(Σ 連勝区間損益)
Maximal Consecutive Loss 最大連敗損失 max(Σ 連敗区間損益)
Average Consecutive Wins 平均連勝数 連勝長の平均
Average Consecutive Losses 平均連敗数 連敗長の平均
Balance Drawdown Absolute 残高ドローダウン(絶対値) 初期残高 − min(残高)
Equity Drawdown Absolute 有効証拠金ドローダウン(絶対値) 初期エクイティ − min(エクイティ)
Balance Drawdown Maximal 最大残高ドローダウン max(ピーク − 谷)
Equity Drawdown Maximal 最大エクイティドローダウン max(ピーク − 谷)
Balance Drawdown Relative 残高ドローダウン率 maxDD ÷ ピーク残高 × 100
Equity Drawdown Relative エクイティドローダウン率 maxDD ÷ ピークエクイティ × 100

現時点では、この中から実際に使用頻度が高い主要な指標のみを実装していきます。

tester.py

    def __TesterDeinit(self):
        
        profits = []
        losses = []
        total_trades = 0
        
        max_consec_win_count = 0
        max_consec_win_money = 0.0

        max_consec_loss_count = 0
        max_consec_loss_money = 0.0

        max_profit_streak_money = 0.0
        max_profit_streak_count = 0

        max_loss_streak_money = 0.0
        max_loss_streak_count = 0

        cur_win_count = 0
        cur_win_money = 0.0

        cur_loss_count = 0
        cur_loss_money = 0.0

        win_streaks = []
        loss_streaks = []
        
        short_trades_won = 0
        long_trades_won = 0
        
        for deal in self.__deals_history_container__:
            if deal.entry == self.mt5_instance.DEAL_ENTRY_OUT: # a closed position
                
                total_trades +=1
                
                profit = deal.profit
                    
                if profit > 0: # A win
                    
                    profits.append(profit)
                    
                    # reset loss streak
                    if cur_loss_count > 0:
                        loss_streaks.append(cur_loss_count)
                        cur_loss_count = 0
                        cur_loss_money = 0.0

                    cur_win_count += 1
                    cur_win_money += profit

                    # longest win streak
                    if cur_win_count > max_consec_win_count:
                        max_consec_win_count = cur_win_count
                        max_consec_win_money = cur_win_money

                    # most profitable win streak
                    if cur_win_money > max_profit_streak_money:
                        max_profit_streak_money = cur_win_money
                        max_profit_streak_count = cur_win_count

                    if deal.type == self.mt5_instance.DEAL_TYPE_BUY:
                        long_trades_won += 1
                    
                    if deal.type == self.mt5_instance.DEAL_TYPE_SELL:
                        short_trades_won += 1
                        
                else: # A loss
                    
                    losses.append(profit)
                    
                    # reset win streak
                    if cur_win_count > 0:
                        win_streaks.append(cur_win_count)
                        cur_win_count = 0
                        cur_win_money = 0.0

                    cur_loss_count += 1
                    cur_loss_money += profit

                    # longest loss streak
                    if cur_loss_count > max_consec_loss_count:
                        max_consec_loss_count = cur_loss_count
                        max_consec_loss_money = cur_loss_money

                    # largest losing streak
                    if cur_loss_money < max_loss_streak_money:
                        max_loss_streak_money = cur_loss_money
                        max_loss_streak_count = cur_loss_count
                    
        
        self.tester_stats["Gross Profit"] = np.sum(profits) if profits else 0
        self.tester_stats["Gross Loss"] = np.sum(losses) if losses else 0
        self.tester_stats["Net Profit"] = self.tester_stats["Gross Profit"] + self.tester_stats["Gross Loss"]
        
        self.tester_stats["Profit Factor"] = self.tester_stats["Gross Profit"] / self.tester_stats["Gross Loss"]
        
        self.tester_stats["Expected Payoff"] = (
            self.tester_stats["Net Profit"] / total_trades
            if total_trades > 0 else 0
        )

        def max_drawdown(curve):
            peak = curve[0]
            max_dd = 0.0

            for value in curve:
                peak = max(peak, value)
                dd = peak - value
                max_dd = max(max_dd, dd)

            return max_dd

        returns = np.diff(self.tester_curves["equity"])

        sharpe = (
            np.mean(returns) / np.std(returns)
            if len(returns) > 1 and np.std(returns) > 0 else 0.0
        )
        
        self.tester_stats["Sharpe Ratio"] = sharpe
        
        self.tester_stats["Equity Drawdown Absolute"] = max_drawdown(self.tester_curves["equity"])
        self.tester_stats["Balance Drawdown Absolute"] = max_drawdown(self.tester_curves["balance"])
        
        self.tester_stats["Recovery Factor"] = (
            self.tester_stats["Net Profit"] / max(self.tester_stats["Balance Drawdown Absolute"], 1)
        )

        self.tester_stats["Equity Drawdown Relative"] = (
            self.tester_stats["Equity Drawdown Absolute"] / max(self.tester_curves["equity"]) * 100
            if self.tester_curves["equity"] else 0.0
        )
        
        self.tester_stats["Balance Drawdown Relative"] = (
            self.tester_stats["Balance Drawdown Absolute"] / max(self.tester_curves["balance"]) * 100
            if self.tester_curves["balance"] else 0.0
        )
        
        self.tester_stats["Balance Drawdown Maximal"] = max_drawdown(self.tester_curves["balance"])
        self.tester_stats["Equity Drawdown Maximal"] = max_drawdown(self.tester_curves["equity"])
        
        self.tester_stats["Total Trades"] = total_trades
        self.tester_stats["Total Deals"] = len(self.__deals_history_container__)
        
        self.tester_stats["Short Trades Won"] = short_trades_won
        self.tester_stats["Long Trades Won"] = long_trades_won
        
        self.tester_stats["Profit Trades"] = len(profits) if profits else 0
        self.tester_stats["Loss Trades"] = len(losses) if losses else 0
        
        self.tester_stats["Largest Profit Trade"] = np.max(profits) if profits else 0
        self.tester_stats["Largest Loss Trade"] = np.min(losses) if losses else 0
        
        self.tester_stats["Average Profit Trade"] = np.mean(profits) if profits else 0
        self.tester_stats["Average Loss Trade"] = np.mean(losses) if losses else 0
        
        self.tester_stats["Maximum Consecutive Wins"] = max_profit_streak_count
        self.tester_stats["Maximum Consecutive Losses"] = max_loss_streak_count
        
        self.tester_stats["Maximum Consecutive Wins Money"] = max_profit_streak_money
        self.tester_stats["Maximum Consecutive Losses Money"] = max_loss_streak_money
        
        self.tester_stats["Average Consecutive Wins"] = np.mean(win_streaks)
        self.tester_stats["Average Consecutive Losses"] = np.mean(loss_streaks)
        
        # AHPR / GHPR
        
        self.tester_stats["AHPR"] = np.prod(1 + returns) ** (1/len(returns)) if len(returns) else 0
        self.tester_stats["GHPR"] = np.prod(1 + returns) if len(returns) else 0

「__GenerateTesterReport」関数の中では、これらの統計指標をHTMLテンプレートに描画します。これは、注文や約定を表示したときと同様の方法です。

        stats_table = f"""
            <table class="report-table table-sm table-striped">
                <tbody>
                    <tr>
                        <th>Initial Deposit</th><td class="number">{self.tester_config.get('deposit', 0)}</td>
                        <th>Ticks</th><td class="number">{self.tester_stats.get('Ticks', 0)}</td>
                        <th>Symbols</th><td class="number">{self.tester_stats.get('Symbols', 0)}</td>
                    </tr>
                    <tr>
                        <th>Total Net Profit</th><td class="number">{self.tester_stats.get('Net Profit', 0):.2f}</td>
                        <th>Balance Drawdown Absolute</th><td class="number">{self.tester_stats.get('Balance Drawdown Absolute', 0):.2f}</td>
                        <th>Equity Drawdown Absolute</th><td class="number">{self.tester_stats.get('Equity Drawdown Absolute', 0):.2f}</td>
                    </tr>
                    <tr>
                        <th>Gross Profit</th><td class="number">{self.tester_stats.get('Gross Profit', 0):.2f}</td>
                        <th>Balance Drawdown Maximal</th><td class="number">{self.tester_stats.get('Balance Drawdown Maximal', 0):.2f}</td>
                        <th>Equity Drawdown Maximal</th><td class="number">{self.tester_stats.get('Equity Drawdown Maximal', 0):.2f}</td>
                    </tr>
                    <tr>
                        <th>Gross Loss</th><td class="number">{self.tester_stats.get('Gross Loss', 0):.2f}</td>
                        <th>Balance Drawdown Relative</th><td class="number">{self.tester_stats.get('Balance Drawdown Relative', 0):.2f}%</td>
                        <th>Equity Drawdown Relative</th><td class="number">{self.tester_stats.get('Equity Drawdown Relative', 0):.2f}%</td>
                    </tr>
                    <tr>
                        <th>Profit Factor</th><td class="number">{self.tester_stats.get('Profit Factor', 0):.2f}</td>
                        <th>Expected Payoff</th><td class="number">{self.tester_stats.get('Expected Payoff', 0):.2f}</td>
                        <th>Margin Level</th><td class="number">{self.tester_stats.get('Margin Level', 0):.2f}%</td>
                    </tr>
                    <tr>
                        <th>Recovery Factor</th><td class="number">{self.tester_stats.get('Recovery Factor', 0):.2f}</td>
                        <th>Sharpe Ratio</th><td class="number">{self.tester_stats.get('Sharpe Ratio', 0):.2f}</td>
                        <th>Z-Score</th><td class="number">{self.tester_stats.get('Z-Score', 0):.2f}</td>
                    </tr>
                    <tr>
                        <th>AHPR</th><td class="number">{self.tester_stats.get('AHPR', 0):.4f}</td>
                        <th>LR Correlation</th><td class="number">{self.tester_stats.get('LR Correlation', 0):.2f}</td>
                        <th>OnTester result</th><td class="number">{self.tester_stats.get('OnTester result', 0)}</td>
                    </tr>
                    <tr>
                        <th>GHPR</th><td class="number">{self.tester_stats.get('GHPR', 0):.4f}</td>
                        <th>LR Standard Error</th><td class="number">{self.tester_stats.get('LR Standard Error', 0):.2f}</td>
                        <td></td><td></td>
                    </tr>
                    <tr>
                        <th>Total Trades</th><td class="number">{self.tester_stats.get('Total Trades', 0)}</td>
                        <th>Short Trades (won %)</th><td class="number">{short_trades_won} ({100*short_trades_won/self.tester_stats.get('Total Trades',1):.2f}%)</td>
                        <th>Long Trades (won %)</th><td class="number">{long_trades_won} ({100*long_trades_won/self.tester_stats.get('Total Trades',1):.2f}%)</td>
                    </tr>
                    <tr>
                        <th>Total Deals</th><td class="number">{self.tester_stats.get('Total Deals', 0)}</td>
                        <th>Profit Trades (% of total)</th><td class="number">{self.tester_stats.get('Profit Trades', 0)} ({100*self.tester_stats.get('Profit Trades',0)/max(self.tester_stats.get('Total Trades',1),1):.2f}%)</td>
                        <th>Loss Trades (% of total)</th><td class="number">{self.tester_stats.get('Loss Trades', 0)} ({100*self.tester_stats.get('Loss Trades',0)/max(self.tester_stats.get('Total Trades',1),1):.2f}%)</td>
                    </tr>
                    <tr>
                        <th>Largest Profit Trade</th><td class="number">{self.tester_stats.get('Largest Profit Trade', 0):.2f}</td>
                        <th>Largest Loss Trade</th><td class="number">{self.tester_stats.get('Largest Loss Trade', 0):.2f}</td>
                        <td></td><td></td>
                    </tr>
                    <tr>
                        <th>Average Profit Trade</th><td class="number">{self.tester_stats.get('Average Profit Trade', 0):.2f}</td>
                        <th>Average Loss Trade</th><td class="number">{self.tester_stats.get('Average Loss Trade', 0):.2f}</td>
                        <td></td><td></td>
                    </tr>
                    <tr>
                        <th>Max Consecutive Wins ($)</th><td class="number">{max_profit_streak_count} ({max_profit_streak_money:.2f})</td>
                        <th>Max Consecutive Losses ($)</th><td class="number">{max_loss_streak_count} ({max_loss_streak_money:.2f})</td>
                        <td></td><td></td>
                    </tr>
                    <tr>
                        <th>Max Consecutive Profit (count)</th><td class="number">{max_profit_streak_count} ({max_profit_streak_money:.2f})</td>
                        <th>Max Consecutive Loss (count)</th><td class="number">{max_loss_streak_count} ({max_loss_streak_money:.2f})</td>
                        <td></td><td></td>
                    </tr>
                    <tr>
                        <th>Average Consecutive Wins</th><td class="number">{self.tester_stats.get('Average Consecutive Wins', 0):.2f}</td>
                        <th>Average Consecutive Losses</th><td class="number">{self.tester_stats.get('Average Consecutive Losses', 0):.2f}</td>
                        <td></td><td></td>
                    </tr>
                </tbody>
            </table>
            """

        # ....
        
        # we populate table's body
        
        html = (
            template
            .replace("{{STATS_TABLE}}", stats_table)
            .replace("{{ORDER_ROWS}}", order_rows_html)
            .replace("{{DEAL_ROWS}}", deal_rows_html)
            .replace(
            "{{CURVE_IMAGE}}",
            f'<img src="{curve_img}" class="img-fluid curve-img">' if curve_img else ""
            )
        )

ここで注目すべき点として、これらの統計指標は表形式で描画しています。これは、MetaTrader 5のストラテジーテスターが生成するような非表形式のレポートとは異なります。表用いることで、生成されたレポートをより見やすく、直感的に理解しやすい形にできます。

レポート内の統計表は次のような見た目になります。

この後は、レポート内に残高カーブをプロットします。

matplotlibを使用します。

    def _plot_tester_curves(self, output_path: str) -> str:
        
        curves = self.tester_curves

        if not curves["time"]:
            return None

        # Convert timestamps → datetime
        times = [
            datetime.fromtimestamp(t) if isinstance(t, (int, float)) else t
            for t in curves["time"]
        ]

        plt.figure(figsize=(10, 4))

        plt.plot(times, curves["balance"], label="Balance", linewidth=2)
        plt.plot(times, curves["equity"], label="Equity", linewidth=2)
        plt.grid(visible=True, which="minor")
        # plt.plot(times, curves["margin"], label="Margin", linewidth=1, alpha=0.6)

        plt.legend(loc="upper right")
        plt.tight_layout()

        plt.savefig(output_path, dpi=150, transparent=True)
        plt.close()

        return output_path

プロット(カーブ)用のデータを取得するためには、テスターの口座状態を各ティックごと、または数回の反復ごとに保存します。この部分は、実装方法を誤るとパフォーマンスに大きな負荷がかかる可能性があるため注意が必要です。

    def __curves_update(self, time):
        
        if isinstance(time, datetime):
            time = time.timestamp()
        
        minute = int(time) // (config.CURVES_PLOT_INTERVAL_MINS*60)

        if minute == self.last_curve_minute:
            return

        self.last_curve_minute = minute

        self.tester_curves["time"].append(time)
        self.tester_curves["balance"].append(self.AccountInfo.balance)
        self.tester_curves["equity"].append(self.AccountInfo.equity)
        self.tester_curves["margin"].append(self.AccountInfo.margin)

このメソッドは、プロットのために使用する各種データとして、現在の時刻、残高、エクイティ、および証拠金をそれぞれ対応する配列へ追加していきます。

    def OnTick(self, ontick_func):
        """Calls the assigned function upon the receival of new tick(s)

        Args:
            ontick_func (_type_): A function to be called on every tick
        """
        
        if not self.IS_TESTER:
            return

        self.__TesterInit()
        
        modelling = self.tester_config["modelling"]
        if modelling == "real_ticks" or modelling == "every_tick":
            
            total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO)

            self.__GetLogger().debug(f"total number of ticks: {total_ticks}")

            with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar:
                while True:
                    
                        # ...
                        
                        current_tick = utils.make_tick_from_tuple(current_tick)
                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        
                        self.__curves_update(current_tick.time)
                        ontick_func()

                        ticks_info["counter"] = counter + 1
                        any_tick_processed = True

                        pbar.update(1)

                    if not any_tick_processed:
                        break
                    
        elif modelling == "new_bar" or modelling == "1-minute-ohlc":
            
            bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO]
            total_bars = sum(bars_)
            
            self.__GetLogger().debug(f"total number of bars: {total_bars}")

            with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar:
                while True:
                    
                    any_bar_processed = False

                    for bars_info in self.TESTER_ALL_BARS_INFO:
                        
                        # ...

                        current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter))
                        
                        self.__curves_update(current_tick["time"])
                        
                        # Getting ticks at the current bar
                        
                        self.TickUpdate(symbol=symbol, tick=current_tick)
                        ontick_func()

                        bars_info["counter"] = counter + 1
                        any_bar_processed = True

                        pbar.update(1)

                    if not any_bar_processed:
                        break
        
        self.__TesterDeinit()

最後に、ストップロスをテイクプロフィットの10倍に設定します。これにより、テスターの結果から何を期待できるかを明確に把握できます。すべての取引においてより高い精度を得るための設計です。これは、今回のプロジェクトがどれだけ現実の挙動に近く、またどれだけ効果的に機能しているかを評価するための良い指標になります。

以下が実行結果です。



MQL5で同様の自動売買ロボットを作成しました。

#include <Trade\Trade.mqh>
#include <Trade\SymbolInfo.mqh>
#include <Trade\PositionInfo.mqh>

CTrade m_trade;
CSymbolInfo m_symbol;
CPositionInfo m_position;

input int magic_number = 10012026;
input int stoploss = 1000;
input int takeprofit = 100;
input int slippage = 100;
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   
   m_symbol.Name(Symbol());
   m_trade.SetExpertMagicNumber(magic_number);
   m_trade.SetDeviationInPoints(slippage);
   m_trade.SetTypeFillingBySymbol(Symbol());
   
//---
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   
  }
//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
    
    if (!m_symbol.RefreshRates())
      return;
    
    double ask = m_symbol.Ask(),
           bid = m_symbol.Bid(),
           pts = m_symbol.Point();
    
    double volume = 0.01;
    
    if (!PosExists(magic_number, POSITION_TYPE_BUY))
      m_trade.Buy(volume, Symbol(), ask, ask-stoploss*pts, ask+takeprofit*pts);
    
    if (!PosExists(magic_number, POSITION_TYPE_SELL))
      m_trade.Sell(volume, Symbol(), bid, bid+stoploss*pts, bid-takeprofit*pts);
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
bool PosExists(int magic, ENUM_POSITION_TYPE type)
 {
   for (int i=PositionsTotal()-1; i>=0; i--)
     if (m_position.SelectByIndex(i))
        if (m_position.Magic() == magic && m_position.PositionType() == type)
           return true;
           
   return false;
 }

カスタムテスターと同様の環境でテストを実行した結果は以下のとおりです。

結果は精度の面ではかなり近いものでしたが、最終的にシミュレーターはMetaTrader 5のストラテジーテスターと比べて取引数が少なくなりました。この差異は予想されていたものであり、プロジェクトにはまだ大きな改善の余地が残されています。

ご意見やご感想をGitHub(https://github.com/MegaJoctan/PyMetaTester)で共有し、このプロジェクトの改善にご協力ください。


まとめ

ティックとバーの扱い方を再現し、ヒストリーデータをループしながら取引戦略のメイン関数を呼び出す仕組みを導入できたことで、本プロジェクトはより信頼性のあるものになりました。

MetaTrader 5風のストラテジーテスターレポートは、新機能の検証やカスタムシミュレーターのデバッグに非常に役立ちます。まだバグや未実装のメトリクスは残っていますが、Pythonベースの独自ストラテジーテスターを継続的に改善していく上では、それでも十分に価値があります。 

今後もさらなる改善が予定されていますので、ぜひ次回をお楽しみに。


添付ファイルの表 

ファイル名 説明と使用法
requirements.txt このプロジェクトで使用するPython依存関係とそのバージョンを記載したファイル
configs\tester.json テスターで使用する調整可能なパラメータを含む設定用JSONファイル
Reports\template.html Testerクラスが生成するレポート用HTMLテンプレート
src\bars.py シミュレーション用にMetaTrader 5からバー情報を取得する関数
src\ticks.py シミュレーション用にMetaTrader 5からティックデータを取得する関数
src\ticks_gen.py MetaTrader 5のようなティックを生成するロジックを持つクラス
Trade\Trade.py MetaTrader 5-Pythonでの取引を簡易化するCTradeクラス
config.py プロジェクト全体で使用される設定変数を管理するPython設定ファイル 
example_bot.py シミュレーター上で動作するEA風取引ロジックの例
tester.py  プロジェクトのコアとなるTesterクラス(エンジン部分) 
validators.py 入力データ検証などをおこなうユーティリティ関数
utils.py  プロジェクト全体で再利用されるPython汎用関数
Example EA.mq5  example_bot.pyと同様の戦略を持つMQL5版EA

このEAは、MetaTrader 5ストラテジーテスターと自作テスターの結果を比較する際に役立ちます。 

MetaQuotes Ltdにより英語から翻訳されました。
元の記事: https://www.mql5.com/en/articles/20917

添付されたファイル |
Attachments.zip (39.73 KB)
最後のコメント | ディスカッションに移動 (4)
fxsaber
fxsaber | 23 1月 2026 において 10:37
<img width="600" height="614" src="https://c.mql5.com/2/189/progress_bar.gif" loading="lazy" alt/ translate="no">

研究者にとって、テスターの性能は極めて重要な指標 です。 テスターのメモリ消費量を提供していただけると助かります。


0.2百万ティック/秒という のは、残念ながら強い制限です。 おそらくNumbaが パフォーマンスの向上に役立つでしょう。


異なる数の取引シンボル用の)セクションを追加してください:

benchmark_python vs benchmark_MT5tester,  (single/optimization).
RAM_python  vs RAM_MT5tester,  (single/optimization).


記事をありがとうございました!

Omega J Msigwa
Omega J Msigwa | 23 1月 2026 において 16:59
fxsaber #:

研究者にとって、テスターの性能は極めて重要な指標 です。 テスターのメモリー消費量を提供するのが良いでしょう。


0.2百万ティック/秒という のは、残念ながら強い制限です。 おそらくNumbaが パフォーマンスの向上に役立つでしょう。


異なる取引シンボル数用の)セクションを追加してください:


記事をありがとうございました!

ご提案ありがとうございます。

目標は、最初に実装し、後で改善することでした。

Richard Poster
Richard Poster | 27 1月 2026 において 16:46
これはまさに私が探していたツールだ!今後、パラメーター・オプティマイザーを実装する予定はありますか?
Omega J Msigwa
Omega J Msigwa | 28 1月 2026 において 02:43
Richard Poster #:
これはまさに私が探していたツールだ!今後、パラメータ・オプティマイザを実装する予定はありますか?
はい。
MQL5取引ツール(第11回):ヒートマップおよび標準モード対応相関行列ダッシュボード(ピアソン、スピアマン、ケンドール) MQL5取引ツール(第11回):ヒートマップおよび標準モード対応相関行列ダッシュボード(ピアソン、スピアマン、ケンドール)
MQL5で相関行列ダッシュボードを構築し、ピアソン、スピアマン、ケンドールの各手法を用いて、指定した時間足およびバー数に基づいて資産間の相関関係を算出します。色の閾値と星印によってp値の有意性を示す標準モードに加え、相関の強さをグラデーションで可視化するヒートマップモードを実装します。さらに、時間足選択ツール、モード切り替え、動的な凡例を備えたインタラクティブなユーザーインターフェースを搭載しており、銘柄間の依存関係を効率的に分析できます。
MQL5入門(第35回):MQL5のAPIとWebRequest関数の習得(IX) MQL5入門(第35回):MQL5のAPIとWebRequest関数の習得(IX)
MetaTrader 5でユーザー操作を検出する方法、AI APIへリクエストを送信する方法、応答を抽出する方法を学び、パネルにスクロールテキストを実装します。
エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法 エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
この記事では、MT4において複数のEAの衝突をさける方法を扱います。ターミナルの操作、MQL4の基本的な使い方がわかる人にとって、役に立つでしょう。
Python-MetaTrader 5ストラテジーテスター(第3回):MetaTrader 5風の取引操作 — 処理と管理 Python-MetaTrader 5ストラテジーテスター(第3回):MetaTrader 5風の取引操作 — 処理と管理
シミュレーター内で注文の開始、終了、変更などの取引操作を処理するための、Python-MetaTrader5と同様の方法を紹介します。シミュレーションがMT5と同様の動作となるように、取引リクエストに対して厳密な検証処理が実装されており、銘柄取引パラメータや一般的なブローカーの制限事項が考慮されています。