Python-MetaTrader 5ストラテジーテスター(第4回):テスター入門
内容
- はじめに
- シミュレーターからテスターへ
- テスターの設定と初期化
- 実ティックに基づくストラテジーテスト
- 合成ティックに基づくストラテジーテスト
- 新しいバーに基づくストラテジーテスト
- 1分足OHLCに基づくストラテジーテスト
- OnTick関数内での統合処理
- ストラテジーテスター内での実際の取引処理
- ストラテジーテストレポートの生成
- 結論
はじめに
これまでの記事では、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 | テスト中にティックをどのように生成するかを決定します。対応するモデルは以下の通りです。
|
| 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
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
MQL5取引ツール(第11回):ヒートマップおよび標準モード対応相関行列ダッシュボード(ピアソン、スピアマン、ケンドール)
MQL5入門(第35回):MQL5のAPIとWebRequest関数の習得(IX)
エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
Python-MetaTrader 5ストラテジーテスター(第3回):MetaTrader 5風の取引操作 — 処理と管理
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索
研究者にとって、テスターの性能は極めて重要な指標 です。 テスターのメモリ消費量を提供していただけると助かります。
0.2百万ティック/秒という のは、残念ながら強い制限です。 おそらくNumbaが パフォーマンスの向上に役立つでしょう。
異なる数の取引シンボル用の)セクションを追加してください:
記事をありがとうございました!
研究者にとって、テスターの性能は極めて重要な指標 です。 テスターのメモリー消費量を提供するのが良いでしょう。
0.2百万ティック/秒という のは、残念ながら強い制限です。 おそらくNumbaが パフォーマンスの向上に役立つでしょう。
異なる取引シンボル数用の)セクションを追加してください:
記事をありがとうございました!
ご提案ありがとうございます。
目標は、最初に実装し、後で改善することでした。
これはまさに私が探していたツールだ!今後、パラメータ・オプティマイザを実装する予定はありますか?