またAIが「完璧なロジック」を書き上げたと思い込んで、相場という名のシュレッダーに資金を投げ込むレポートが届いた。

今回の被験体は DynamicMomentumScoreStrategy。名前だけは立派だ。ダイナミック、モメンタム、スコアリング。最近のLLM(大規模言語モデル)が好む「それっぽく聞こえる統計用語」を盛り盛りにした、典型的なオーバーエンジニアリングの産物である。

導入:AIが描いた「勝ち筋」の幻想

このEAの設計思想をコードから読み解くと、AIはこう考えたらしい。 「単純なブレイクアウトはダマシが多い。ならば、線形回帰の勾配、Z-scoreによる乖離率、出来高の相対強度、価格効率性(Efficiency Ratio)、そしてローソク足の実体強度という5つのフィルターを設け、それをスコアリングして閾値を超えた時だけエントリーすれば、高期待値のトレードだけを抽出できるはずだ」

なるほど。教科書的なテクニカル指標を組み合わせれば、確率論的に勝てると思われたのだろう。5分足という短期足で、複数の時間軸(視覚的には)を意識しつつ、統計的に「正当化された」ブレイクアウトを狙う。一見すると理知的だが、クオンツの視点から見れば「何を信じていいか分からず、とりあえず全部入れた」迷走の跡に見える。

破綻の解説:なぜこのロジックはゴミなのか

結果は悲惨だ。PF 0.82。 1.2以上を合格ラインとする中で、この数値は「トレードすればするほど、統計的に確実に資金が減る」ことを意味している。

最大ドローダウンが0.4%と極めて低い点に騙されてはいけない。これはリスク管理が優秀なのではなく、単に期待値がマイナスのトレードを大量に繰り返し、緩やかに、そして確実に死に向かっているだけだ。

技術的にこのロジックが破綻した理由は明白である。

1. 「後出しジャンケン」的なスコアリング

このEAは、ピボット(直近高値・安値)をブレイクした「後」に、その足の終値を用いてスコアを計算している。 5分足の世界において、価格効率性や実体強度が高い状態で確定したということは、すでに価格が伸びきった後である可能性が極めて高い。つまり、AIは「勢いよく上がった後」に、「これは勢いがあるから買いだ!」と判断してエントリーしている。典型的な「高値掴み」マシンだ。

2. ボラティリティを無視した固定TP/SL

TP 20pips / SL 15pips。この数値の根拠はどこにある? Z-scoreや線形回帰といった動的な指標を使いながら、出口戦略だけは固定値という矛盾。ボラティリティが低い局面ではTPに届かず、高い局面では一瞬でSLに狩られる。統計的アプローチを謳うなら、ATR(Average True Range)に基づいた動的なエグジットを実装すべきだった。

3. スプレッドという現実の壁

取引回数2116回。この回数でPF 0.82ということは、1回あたりの期待利得が極めて低い。ここに実運用のスプレッドと滑りを加味すれば、バックテストの結果以上に悲惨なカーブを描くだろう。AIは往々にして「スプレッド 0」の理想郷で計算を行うが、現実は非情である。

結論として、このEAは「統計的な指標を組み合わせればフィルタリングできる」というナイーブな幻想に基づいた、単なる遅行指標の寄せ集めに過ぎない。

失敗したPythonコード

反面教師として、AIが書き上げた「もっともらしいが使い物にならない」コードを公開する。

import numpy as np
import pandas as pd
from strategies.base import BaseStrategy

class DynamicMomentumScoreStrategy(BaseStrategy):
    """
    Dynamic Momentum Score (DMS) Strategy:
    厳格なフィルター条件を捨て、複数の統計的指標をスコアリングすることで、
    取引回数の確保と高期待値の両立を目指す構造的ブレイクアウト戦略。
    """
    def __init__(self):
        # 親クラスの初期化: TP 20pips, SL 15pips でバランスを調整
        super().__init__(name="DynamicMomentumScoreStrategy", default_tp_pips=20.0, default_sl_pips=15.0)
        
        self.base_timeframe = "5m"
        self.vision_timeframes = ["5m", "15m", "1h"]
        
        # パラメータ設定
        self.short_window = 5
        self.mid_window = 20
        self.long_window = 60
        self.z_min = 0.8           # エントリー閾値を緩和
        self.z_max = 2.2           # 過熱圏の定義
        self.score_threshold = 3   # エントリーに必要な最低スコア

    def _calculate_slope(self, series):
        """線形回帰の勾配を計算"""
        y = series.values
        x = np.arange(len(y))
        if len(y) < 2: return 0.0
        slope, _ = np.polyfit(x, y, 1)
        return slope

    def calculate_indicators(self, df):
        """
        スコアリングに使用する統計指標を計算する。
        """
        # 1. モメンタム勾配
        df['slope_short'] = df['Close'].rolling(window=self.short_window).apply(self._calculate_slope, raw=False)
        df['slope_mid'] = df['Close'].rolling(window=self.mid_window).apply(self._calculate_slope, raw=False)
        
        # 2. 統計的乖離 (Z-score)
        df['std_mid'] = df['Close'].rolling(window=self.mid_window).std()
        df['mean_mid'] = df['Close'].rolling(window=self.mid_window).mean()
        df['z_score'] = (df['Close'] - df['mean_mid']) / (df['std_mid'] + 1e-9)
        
        # 3. 出来高の相対強度
        df['vol_sma'] = df['Volume'].rolling(window=self.mid_window).mean()
        df['rel_volume'] = df['Volume'] / (df['vol_sma'] + 1e-9)
        
        # 4. 価格効率性
        net_change = df['Close'].diff(5).abs()
        sum_abs_change = df['Close'].diff().abs().rolling(window=5).sum()
        df['efficiency'] = net_change / (sum_abs_change + 1e-9)
        
        # 5. ローソク足の実体強度
        df['body_size'] = (df['Close'] - df['Open']).abs()
        df['candle_range'] = df['High'] - df['Low']
        df['rel_body_strength'] = df['body_size'] / (df['candle_range'] + 1e-9)

        # 6. 物理的ピボット (ブレイク判定用)
        df['pivot_high'] = df['High'].rolling(window=self.mid_window).max()
        df['pivot_low'] = df['Low'].rolling(window=self.mid_window).min()

        return df

    def generate_signal(self, df):
        """
        スコアリングに基づき、期待値の高いブレイクアウトを検知する。
        """
        if len(df) < self.long_window:
            return None

        last = df.iloc[-1]
        prev = df.iloc[-2]

        # --- 1. 基本トリガー: 物理的ブレイクアウト ---
        # 終値が前回のピボットレンジを更新したか
        break_high = last['Close'] > prev['pivot_high']
        break_low = last['Close'] < prev['pivot_low']
        
        if not (break_high or break_low):
            return None

        # --- 2. スコアリング計算 ---
        score = 0
        
        # A. 勾配の同期 (+2)
        if break_high and last['slope_short'] > 0 and last['slope_mid'] > 0:
            score += 2
        elif break_low and last['slope_short'] < 0 and last['slope_mid'] < 0:
            score += 2
            
        # B. Z-scoreの適正範囲 (+1)
        if self.z_min < abs(last['z_score']) < self.z_max:
            score += 1
            
        # C. 出来高の裏付け (+1)
        if last['rel_volume'] > 1.2:
            score += 1
            
        # D. 効率性の高さ (+1)
        if last['efficiency'] > 0.4:
            score += 1
            
        # E. 実体強度の高さ (+1)
        if last['rel_body_strength'] > 0.6:
            score += 1

        # --- 3. 最終判定 ---
        # スコアが閾値を超え、かつ方向性が一致しているか
        if score >= self.score_threshold:
            if break_high and last['Close'] > last['Open']:
                # 前足の終値より高いことを確認
                if last['Close'] > prev['Close']:
                    return 'BUY'
            
            if break_low and last['Close'] < last['Open']:
                # 前足の終値より低いことを確認
                if last['Close'] < prev['Close']:
                    return 'SELL'

        return None

オマケ:反面教師としてMT5でも試したい物好きな方へ

「このゴミのようなロジックが、MT5の高速な執行環境なら化けるかもしれない」と信じる物好きな方のために、MQL5に移植してやった。どうぞ、ご自身のデモ口座でゆっくりと資金が溶ける様を観察してほしい。

//+------------------------------------------------------------------+
//|                                  DynamicMomentumScoreStrategy.mq5|
//|                                  Cynical Quant Engineer's Port   |
//+------------------------------------------------------------------+
#property copyright "Cynical Quant"
#property link      "https://your-blog-url.com"
#property version   "1.00"
#property strict

input int      ShortWindow = 5;
input int      MidWindow = 20;
input double   ZMin = 0.8;
input double   ZMax = 2.2;
input int      ScoreThreshold = 3;
input double   TakeProfitPips = 20.0;
input double   StopLossPips = 15.0;

// 線形回帰の勾配を計算する関数
double CalculateSlope(const double &data[], int period) {
    if(ArraySize(data) < period) return 0.0;
    double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
    for(int i=0; i<period; i++) {
        sumX += i;
        sumY += data[i];
        sumXY += i * data[i];
        sumX2 += i * i;
    }
    return (period * sumXY - sumX * sumY) / (period * sumX2 - sumX * sumX);
}

void OnTick() {
    MqlRates rates[];
    ArraySetAsSeries(rates, true);
    if(CopyRates(_Symbol, _Period, 0, MidWindow + 1, rates) < MidWindow + 1) return;

    double close[], high[], low[], open[];
    long volume[];
    ArraySetAsSeries(close, true); ArraySetAsSeries(high, true);
    ArraySetAsSeries(low, true); ArraySetAsSeries(open, true);
    ArraySetAsSeries(volume, true);

    for(int i=0; i<=MidWindow; i++) {
        close[i] = rates[i].close;
        high[i] = rates[i].high;
        low[i] = rates[i].low;
        open[i] = rates[i].open;
        volume[i] = rates[i].tick_volume;
    }

    // 1. ピボットブレイク判定
    double pivotHigh = high[ArrayMaximum(high, 1, MidWindow)];
    double pivotLow = low[ArrayMinimum(low, 1, MidWindow)];
    
    bool breakHigh = close[0] > pivotHigh;
    bool breakLow = close[0] < pivotLow;

    if(!breakHigh && !breakLow) return;

    // 2. スコアリング
    int score = 0;

    // A. 勾配の同期
    double sSlope = CalculateSlope(close, ShortWindow);
    double mSlope = CalculateSlope(close, MidWindow);
    if(breakHigh && sSlope < 0 && mSlope < 0) score += 2; // MQL5 indexは逆なので符号反転
    if(breakLow && sSlope > 0 && mSlope > 0) score += 2;

    // B. Z-Score
    double sum = 0;
    for(int i=0; i<MidWindow; i++) sum += close[i];
    double mean = sum / MidWindow;
    double sq_sum = 0;
    for(int i=0; i<MidWindow; i++) sq_sum += MathPow(close[i] - mean, 2);
    double stdDev = MathSqrt(sq_sum / MidWindow);
    double zScore = (close[0] - mean) / (stdDev + 1e-9);
    if(MathAbs(zScore) > ZMin && MathAbs(zScore) < ZMax) score += 1;

    // C. 出来高
    double volSum = 0;
    for(int i=0; i<MidWindow; i++) volSum += (double)volume[i];
    double volSma = volSum / MidWindow;
    if((double)volume[0] / (volSma + 1e-9) > 1.2) score += 1;

    // D. 価格効率性 (Efficiency Ratio)
    double netChange = MathAbs(close[0] - close[5]);
    double sumAbsChange = 0;
    for(int i=0; i<5; i++) sumAbsChange += MathAbs(close[i] - close[i+1]);
    if(netChange / (sumAbsChange + 1e-9) > 0.4) score += 1;

    // E. 実体強度
    double bodySize = MathAbs(close[0] - open[0]);
    double candleRange = high[0] - low[0];
    if(bodySize / (candleRange + 1e-9) > 0.6) score += 1;

    // 3. エントリー判定
    if(score >= ScoreThreshold) {
        if(breakHigh && close[0] > open[0] && close[0] > close[1]) {
            // BUY order logic here
        }
        if(breakLow && close[0] < open[0] && close[0] < close[1]) {
            // SELL order logic here
        }
    }
}