English Deutsch
preview
口座ダイナミクスの追跡:MQL5による残高、エクイティ、含み損益の可視化

口座ダイナミクスの追跡:MQL5による残高、エクイティ、含み損益の可視化

MetaTrader 5インディケータ |
13 3
Nervada Emeule Adams
Nervada Emeule Adams

はじめに

ほとんどのトレーダーはチャート分析やインジケーター、取引戦略に多くの時間を費やしていますが、実際の口座が時間の経過とともにどのように推移しているかに細かく注意を払っている人は意外に少ないものです。MetaTrader 5では現在の残高やエクイティを確認できますが、これらの数値が取引開始以来どのように連動し、あるいは乖離しながら推移してきたのかを履歴として表示する機能は提供されていません。現在の状況は確認できますが、そこに至るまでの過程を見ることはできないのです。

これは、単純な勝率や総利益を超えて取引成績を分析したい場合に問題となります。時間の経過に伴う残高、エクイティ、含み損益の関係を理解することで、リスク管理のパターン、ポジション保有行動、そして口座全体の状態についての洞察が得られます。このような視覚的なコンテキストがなければ、口座の統計的な傾向を把握できないまま取引していることになります。

一般的な解決策としては、データをスプレッドシートにエクスポートしたり、サードパーティの分析プラットフォームを利用したりする方法があります。これらの方法は機能しますが、取引ターミナルから離れて作業する必要があり、多くの場合、手動で更新しなければなりません。もし、これらをすべてMetaTrader 5内で直接確認でき、サブウィンドウに見やすいラインとして表示され、取引に合わせて自動的に更新されるとしたらどうでしょうか。

本記事では、まさにそれを実現するカスタムインジケーターの構築方法を解説します。このインジケーターは完全な取引履歴を再構築し、4つの主要な曲線を描画します。すなわち、基準線としての開始残高、残高の推移、エクイティの推移、そして含み損益です。すべての処理はMetaTrader 5内で完結し、外部依存はありません。インジケーターはパフォーマンスを維持するためにバーごとにデータをサンプリングしながら、取引しているすべての銘柄にまたがるポジションを追跡します。最終的には、実際の取引環境において、口座全体がどのように推移・変動してきたのかを明らかにする分析ツールを手に入れることができます。


可視化アプローチ

標準のMetaTraderによる口座モニタリングでは、個別の時点におけるスナップショットしか表示されないため、結果が実際にどのように達成されたのかを理解することが困難です。残高、エクイティ、含み損益を連続的な時系列として扱うことで、これらの指標は、保有行動やドローダウン、回復のダイナミクスを物語る視覚的な時系列表現となります。

バー単位でのサンプリングは、精度とパフォーマンスの適切なバランスを提供し、口座曲線を価格チャートと整合させながら、長期間の履歴に対しても効率的に動作します。複数銘柄の追跡は価格キャッシュによって実用的なものとなり、深刻なパフォーマンス上の問題を回避できます。

正確な再構築には、初回入金から開始して全ディール(約定)履歴を時系列順に処理し、その過程でポジションの状態を維持する必要があります。これを正しく実装することで、口座の過去の挙動を完全に表現する4本の曲線を生成できます。

可視化プロセスフローチャート
図1: 履歴データの読み込みから最終的な曲線出力までの処理を示した、口座推移の可視化ワークフロー


MQL5での実装

インジケーターのプロパティおよびバッファの設定

このインジケーターは独立したサブウィンドウで動作し、4本の異なる曲線を描画します。以下は、インジケーターにおけるプロパティ宣言およびバッファ宣言の完全なセクションです。

//+------------------------------------------------------------------+
//|                                   AccountDynamicsIndicator.mq5   |
//|                                  Copyright 2025, Persist FX      |
//|                                     Educational Analysis Tool    |
//+------------------------------------------------------------------+
#property copyright "Copyright 2025, Persist FX"
#property link      "https://www.mql5.com"
#property version   "2.0"
#property description "Visual analysis of account balance, equity, and floating P/L dynamics."
#property description "Displays historical account statistics for analytical purposes."
#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   4

//--- plot Starting Balance (Reference Line)
#property indicator_label1  "Starting Balance"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrGray
#property indicator_style1  STYLE_DASH
#property indicator_width1  1

//--- plot Balance
#property indicator_label2  "Balance"
#property indicator_type2   DRAW_LINE
#property indicator_color2  clrOrange
#property indicator_style2  STYLE_SOLID
#property indicator_width2  2

//--- plot Equity
#property indicator_label3  "Equity"
#property indicator_type3   DRAW_LINE
#property indicator_color3  clrDodgerBlue
#property indicator_style3  STYLE_SOLID
#property indicator_width3  2

//--- plot Floating P/L
#property indicator_label4  "Floating P/L"
#property indicator_type4   DRAW_LINE
#property indicator_color4  clrLime
#property indicator_style4  STYLE_SOLID
#property indicator_width4  1

//--- input parameters
input int InpHistoryBars = 0;           // Number of bars to display (0 = all available)
input int InpUpdatePeriod = 300;        // History update period in seconds (default 5 min)

//--- indicator buffers
double StartingBalanceBuffer[];
double BalanceBuffer[];
double EquityBuffer[];
double FloatingBuffer[];

開始残高は、動的なデータではなく基準点であることを明確にするため、灰色の破線で表示されます。残高とエクイティは、トレーダーが主に注目する指標であるため、より太い実線で表示されます。InpUpdatePeriodのデフォルト値は300秒(5分)に設定されており、新たに決済された取引の確認において、応答性と計算負荷のバランスを取っています。

履歴追跡のためのデータ構造

このインジケーターは、複数回の計算サイクルにわたって履歴状態を維持する必要があります。このデータを効率的に管理するために、3つのカスタム構造体を使用します。

//--- structure to store balance changes over time
struct BalancePoint
{
   datetime time;
   double balance;
};

//--- structure for position tracking
struct PositionInfo
{
   ulong ticket;
   string symbol;
   datetime openTime;
   datetime closeTime;
   double openPrice;
   double volume;
   ENUM_POSITION_TYPE type;
   double tickSize;
   double tickValue;
};

//--- price cache for multi-symbol efficiency
struct PriceCache
{
   string symbol;
   datetime time;
   double price;
};

BalancePoint構造体は、残高がいつ変化したのか、そしてその新しい値が何であったのかを追跡します。決済された各取引によって、新しい BalancePoint が作成されます。PositionInfo構造体には、ポジションの過去の含み損益を計算するために必要なすべての情報が格納されます。ここで注目すべき点は、tickSizetickValueを計算のたびに取得するのではなく、この構造体内にキャッシュしていることです。これは、複数銘柄を扱う際の重要なパフォーマンス最適化です。

PriceCache構造体は、大きなパフォーマンス上のボトルネックを解決します。キャッシュがなければ、過去のバーを処理する際に、同じ銘柄と時刻に対して CopyRates()を何度も呼び出すことになります。取得した価格を保存することで、計算量はO(n²)からO(n)に近いものへと改善されます。

BalancePoint balanceHistory[];
PositionInfo positionCache[];
datetime lastHistoryUpdate = 0;
datetime earliestDealTime = 0;
double initialDeposit = 0;
int totalDealsProcessed = 0;

//--- price cache for multi-symbol efficiency
PriceCache priceCache[];

これらのグローバル配列はOnCalculate()の呼び出し間で保持され、インジケーターのライフタイム全体にわたって状態を維持します。totalDealsProcessed変数を使用することで、履歴を不必要に再構築することなく、新たな取引が決済されたことを検出できます。earliestDealTimeは、チャート上で曲線をどこから描画し始めるかを決定し、取引活動が存在しなかった期間に対して計算を試みることを防ぎます。

初期化処理(OnInit)

OnInit()関数は、インジケーターが読み込まれた際に一度だけ実行されます。ここでは、バッファ配列をインジケーターシステムに関連付けるとともに、口座履歴全体を構築します。

int OnInit()
{
   //--- indicator buffers mapping
   SetIndexBuffer(0, StartingBalanceBuffer, INDICATOR_DATA);
   SetIndexBuffer(1, BalanceBuffer, INDICATOR_DATA);
   SetIndexBuffer(2, EquityBuffer, INDICATOR_DATA);
   SetIndexBuffer(3, FloatingBuffer, INDICATOR_DATA);
   
   //--- set arrays as series
   ArraySetAsSeries(StartingBalanceBuffer, true);
   ArraySetAsSeries(BalanceBuffer, true);
   ArraySetAsSeries(EquityBuffer, true);
   ArraySetAsSeries(FloatingBuffer, true);
   
   //--- set precision
   IndicatorSetInteger(INDICATOR_DIGITS, 2);
   
   //--- set indicator short name
   IndicatorSetString(INDICATOR_SHORTNAME, "Account Dynamics");
   
   //--- initialize buffers
   ArrayInitialize(StartingBalanceBuffer, EMPTY_VALUE);
   ArrayInitialize(BalanceBuffer, EMPTY_VALUE);
   ArrayInitialize(EquityBuffer, EMPTY_VALUE);
   ArrayInitialize(FloatingBuffer, EMPTY_VALUE);
   
   //--- build complete account history
   if(!BuildAccountHistory())
   {
      Print("Warning: Could not build complete account history");
   }
   
   lastHistoryUpdate = TimeCurrent();
   
   return(INIT_SUCCEEDED);
}

SetIndexBuffer()関数は、配列をインジケーターの描画システムに関連付けます。第2引数のINDICATOR_DATAは、これらのバッファが描画対象となる値を保持していることを MQL5 に伝えます。これに対して、INDICATOR_CALCULATIONSは表示を目的としない中間計算値を格納するために使用されます。バッファインデックスは、プロパティセクションで宣言した順序と一致していなければなりません。

ArraySetAsSeries()の呼び出しは不可欠です。デフォルトでは、MQL5 の配列は古いデータから新しいデータへ向かってインデックス付けされますが、チャートデータは本来、新しいデータから古いデータへ向かって並びます。配列をシリーズとして設定することでインデックス順序が反転し、インデックス 0 が常に最新のバーを表すようになります。これは OnCalculate()内で使用される時系列配列の動作と一致します。これをおこなわなければ、データは逆向きに描画されてしまいます。

すべてのバッファはEMPTY_VALUEで初期化されます。これは、その地点では何も描画しないようターミナルに指示するものです。これにより、データが存在しない領域に不要な線が表示されるのを防ぎます。BuildAccountHistory()関数は、取引履歴を再構築する中核的な処理を担当しており、次にその内容を見ていきます。

履歴データの再構築 (BuildAccountHistory)

この関数は、口座で実行されたすべてのディールを処理することで、完全な取引履歴を再構築します。インジケーターの中で最も複雑な部分ですが、正確な可視化を実現するうえで最も重要な部分でもあります。

bool BuildAccountHistory()
{
   datetime toDate = TimeCurrent();
   datetime fromDate = 0; // From account inception
   
   if(!HistorySelect(fromDate, toDate))
   {
      Print("Error: Failed to load history - ", GetLastError());
      return false;
   }
   
   //--- clear previous data
   ArrayResize(balanceHistory, 0);
   ArrayResize(positionCache, 0);
   ArrayResize(priceCache, 0);
   
   int totalDeals = HistoryDealsTotal();
   totalDealsProcessed = totalDeals;
   
   if(totalDeals == 0)
   {
      Print("No trading history found");
      initialDeposit = AccountInfoDouble(ACCOUNT_BALANCE);
      AddBalancePoint(TimeCurrent(), initialDeposit);
      earliestDealTime = TimeCurrent();
      return true;
   }
   
   Print("Processing ", totalDeals, " deals from account history...");

HistorySelect()関数は、ディール履歴をメモリに読み込みます。fromDate = 0を指定することで、口座の最初の取引からすべての履歴を要求します。これはトレード履歴とは異なります。ディールには、入金、出金、残高操作、そしてエントリーおよびエグジットの取引の両方が含まれます。この関数は、履歴サーバーが利用できない場合や接続に問題がある場合にはfalseを返します。

読み込み後、 HistoryDealsTotal()によってディールレコードの総数を取得できます。これがゼロの場合、取引活動のない新規口座である可能性が高いです。その場合は、現在の残高を基準として単一の BalancePoint を作成し、処理を終了します。取引履歴が存在する口座では、初回入金と最も早い取引タイムスタンプを特定する必要があります。

//--- find earliest deal and initial deposit
   earliestDealTime = TimeCurrent();
   double runningBalance = 0;
   bool foundInitialDeposit = false;
   
   for(int i = 0; i < totalDeals; i++)
   {
      ulong dealTicket = HistoryDealGetTicket(i);
      if(dealTicket == 0) continue;
      
      datetime dealTime = (datetime)HistoryDealGetInteger(dealTicket, DEAL_TIME);
      ENUM_DEAL_TYPE dealType = (ENUM_DEAL_TYPE)HistoryDealGetInteger(dealTicket, DEAL_TYPE);
      
      //--- find first balance operation (deposit/credit)
      if(!foundInitialDeposit && (dealType == DEAL_TYPE_BALANCE || dealType == DEAL_TYPE_CREDIT))
      {
         initialDeposit = HistoryDealGetDouble(dealTicket, DEAL_PROFIT);
         runningBalance = initialDeposit;
         earliestDealTime = dealTime;
         foundInitialDeposit = true;
         AddBalancePoint(dealTime, runningBalance);
      }
      
      if(dealTime < earliestDealTime)
         earliestDealTime = dealTime;
   }
   
   //--- if no initial deposit found, use first deal as reference
   if(!foundInitialDeposit)
   {
      initialDeposit = AccountInfoDouble(ACCOUNT_BALANCE);
      runningBalance = initialDeposit;
      AddBalancePoint(earliestDealTime, initialDeposit);
   }

HistoryDealGetTicket()関数は、各ディールの一意の識別子を順次取得します。その後、HistoryDealGetInteger()およびHistoryDealGetDouble()を用いて、特定のディール属性を抽出します。DEAL_TYPE_BALANCEは残高操作(入出金など)を表しDEAL_TYPE_CREDITはボーナスクレジットを表します。これらのいずれかの最初の出現が初回入金として扱われ、それ以降のすべての計算の起点となります。

一部の口座では、初期資本として作成されたために明示的な入金ディールが存在しない場合があります。その場合には、現在の残高を基準値として代替的に使用します。これは完全ではありませんが、可視化の目的には十分に妥当な近似値となります。

//--- process all trading deals chronologically
   for(int i = 0; i < totalDeals; i++)
   {
      ulong dealTicket = HistoryDealGetTicket(i);
      if(dealTicket == 0) continue;
      
      datetime dealTime = (datetime)HistoryDealGetInteger(dealTicket, DEAL_TIME);
      ENUM_DEAL_ENTRY dealEntry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
      ENUM_DEAL_TYPE dealType = (ENUM_DEAL_TYPE)HistoryDealGetInteger(dealTicket, DEAL_TYPE);
      
      //--- skip non-trading deals in this loop
      if(dealType == DEAL_TYPE_BALANCE || dealType == DEAL_TYPE_CREDIT)
         continue;
      
      double dealProfit = HistoryDealGetDouble(dealTicket, DEAL_PROFIT);
      double dealSwap = HistoryDealGetDouble(dealTicket, DEAL_SWAP);
      double dealCommission = HistoryDealGetDouble(dealTicket, DEAL_COMMISSION);
      ulong positionId = HistoryDealGetInteger(dealTicket, DEAL_POSITION_ID);
      string symbol = HistoryDealGetString(dealTicket, DEAL_SYMBOL);

次に、すべてのディールを再度イテレートしますが、今度は取引アクティビティに焦点を当てます。DEAL_ENTRYプロパティは、そのディールがポジションを新規に開いたのか(DEAL_ENTRY_IN)、ポジションを決済したのか(DEAL_ENTRY_OUT)、またはポジションを反転したか(DEAL_ENTRY_INOUT)を示します。この区別は非常に重要です。なぜなら、残高に影響を与えるのは決済約定のみですが、過去の含み損益を計算するためには新規約定の追跡も必要になるためです。

//--- handle position entry
      if(dealEntry == DEAL_ENTRY_IN)
      {
         int size = ArraySize(positionCache);
         ArrayResize(positionCache, size + 1);
         
         positionCache[size].ticket = positionId;
         positionCache[size].symbol = symbol;
         positionCache[size].openTime = dealTime;
         positionCache[size].closeTime = 0;
         positionCache[size].openPrice = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
         positionCache[size].volume = HistoryDealGetDouble(dealTicket, DEAL_VOLUME);
         positionCache[size].type = (dealType == DEAL_TYPE_BUY) ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
         
         //--- cache symbol specifications
         positionCache[size].tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
         positionCache[size].tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
         
         if(positionCache[size].tickSize == 0)
            positionCache[size].tickSize = SymbolInfoDouble(symbol, SYMBOL_POINT);
      }

ポジションが新規作成された際には、その詳細のすべてをpositionCache配列に格納します。closeTimeはゼロに設定され、この時点では当該ポジションが履歴上はまだ未決済であることを示します。続いてSymbolInfoDouble()を用いて、銘柄のティック仕様を即座に取得しキャッシュします。これらの値は、価格変動がどのように損益へ変換されるかを決定する重要な要素です。この段階でキャッシュしておくことで、後続の含み損益計算の際に同じ情報を繰り返し取得する必要がなくなり、数千回に及ぶ冗長なルックアップを回避してパフォーマンスを大幅に改善できます。

//--- handle position exit (balance change)
      else if(dealEntry == DEAL_ENTRY_OUT || dealEntry == DEAL_ENTRY_INOUT)
      {
         //--- mark position as closed
         for(int j = 0; j < ArraySize(positionCache); j++)
         {
            if(positionCache[j].ticket == positionId && positionCache[j].closeTime == 0)
            {
               positionCache[j].closeTime = dealTime;
               break;
            }
         }
         
         //--- update balance
         double balanceChange = dealProfit + dealSwap + dealCommission;
         if(balanceChange != 0)
         {
            runningBalance += balanceChange;
            AddBalancePoint(dealTime, runningBalance);
         }
      }
   }
   
   Print("History built: ", ArraySize(balanceHistory), " balance points, ", 
         ArraySize(positionCache), " positions tracked, Initial deposit: ", initialDeposit);
   
   return true;
}

ポジションが決済された際には、キャッシュ内から該当するエントリーを特定し、closeTimeを設定することで決済済みとしてマークします。これは非常に重要です。なぜなら、含み損益の計算機能は、任意の過去時点においてポジションが開いていたかどうかを把握する必要があるためです。残高が変化するのはポジションが決済されたときのみであるため、利益、スワップ、手数料の純影響を計算し、その結果を新しい残高ポイントとして履歴に追加します。AddBalancePoint()ヘルパー関数は、単純にこのデータをbalanceHistory配列へ追加する役割を持ちます。

メイン計算ループ(OnCalculate)

OnCalculate()関数は、新しい価格データが到着したとき、またはユーザーがチャートをスクロールしたときに毎回実行されます。ここでは、表示対象となる各バーに対して計算済みの値をインジケーターバッファへ格納していきます。

int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
{
   if(rates_total < 1)
      return 0;
   
   ArraySetAsSeries(time, true);
   
   //--- check if we need to update history
   datetime currentTime = TimeCurrent();
   if(currentTime - lastHistoryUpdate > InpUpdatePeriod)
   {
      //--- only rebuild if new deals appeared
      HistorySelect(0, currentTime);
      int currentDeals = HistoryDealsTotal();
      
      if(currentDeals != totalDealsProcessed)
      {
         Print("New deals detected, rebuilding history...");
         BuildAccountHistory();
      }
      
      lastHistoryUpdate = currentTime;
   }

rates_totalパラメータはチャート上に存在するバーの総数を示し、prev_calculatedはすでに処理済みのバー数を示します。最初の呼び出しではprev_calculatedはゼロとなるため、すべてのバーを計算する必要があります。2回目以降の呼び出しでは、通常は最新のバーのみを更新すれば十分です。関数に渡される時系列配列は自動的にシリーズ化されていないため、ArraySetAsSeries(time, true) を呼び出すことで、バッファのインデックス体系と整合させます。

定期的な履歴チェックは重要な最適化手法です。ティックごとに再構築をおこなうのではなく、設定可能な間隔で新しいディールが追加されているかどうかを確認します。HistoryDealsTotal()と保存済みのtotalDealsProcessedを比較することで、変更の有無を即座に判定できます。変更があった場合にのみ、完全な履歴再構築というコストの高い処理を実行します。

//--- calculate bars to process
   int limit = rates_total - prev_calculated;
   if(prev_calculated == 0)
      limit = rates_total;
   
   if(InpHistoryBars > 0 && limit > InpHistoryBars)
      limit = InpHistoryBars;
   
   //--- get current account values
   double currentBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   double currentEquity = AccountInfoDouble(ACCOUNT_EQUITY);

limit変数は、処理すべきバーの数を決定します。prev_calculatedがゼロの場合は、すべてのバーを処理します。それ以外の場合は、前回の呼び出し以降に追加された新しいバーのみを処理します。ユーザーがInpHistoryBarsを通じて最大履歴長を指定している場合、その制限も考慮されます。AccountInfoDouble()関数は、ACCOUNT_BALANCEおよびACCOUNT_EQUITYといった定義済み定数を用いて、ライブの口座情報を取得します。

//--- process each bar
   for(int i = 0; i < limit; i++)
   {
      datetime barTime = time[i];
      
      //--- before earliest deal, no data available
      if(barTime < earliestDealTime)
      {
         StartingBalanceBuffer[i] = EMPTY_VALUE;
         BalanceBuffer[i] = EMPTY_VALUE;
         EquityBuffer[i] = EMPTY_VALUE;
         FloatingBuffer[i] = EMPTY_VALUE;
         continue;
      }
      
      //--- starting balance is constant horizontal line
      StartingBalanceBuffer[i] = initialDeposit;
      
      //--- for the most recent bar, use live account data
      if(i == 0)
      {
         BalanceBuffer[i] = currentBalance;
         FloatingBuffer[i] = currentEquity - currentBalance;
         EquityBuffer[i] = currentEquity;
      }
      else
      {
         //--- get historical balance at this bar
         double balanceAtTime = GetBalanceAtTime(barTime, currentBalance);
         
         //--- calculate historical floating P/L
         double floatingPL = CalculateFloatingPLAtTime(barTime);
         
         BalanceBuffer[i] = balanceAtTime;
         FloatingBuffer[i] = floatingPL;
         EquityBuffer[i] = balanceAtTime + floatingPL;
      }
   }
   
   return rates_total;
}

最初の取引より前のバーについては、すべてのバッファをEMPTY_VALUEに設定し、何も描画されないようにします。開始残高ラインはinitialDepositの固定値を全バーにわたって設定し、水平の基準線として表示されます。インデックス0は常に現在のバーを表し、このバーでは現在の口座データを直接使用します。過去のバーについては、その時点における残高を取得するヘルパー関数を呼び出し、同時に含み損益を計算します。エクイティは、任意の時点において残高に含み損益を加算したものとして定義されます。

rates_totalを返すことで、ターミナルに対してすべての利用可能なバーの処理が正常に完了したことを通知します。この値は次回の呼び出し時にprev_calculatedとして渡されるため、インクリメンタルな更新が可能になります。

含み損益の計算

含み損益の計算は、複数銘柄処理の複雑さが集中する部分です。ここでは、特定時点の残高取得、ポジション評価の計算、そして価格キャッシュの効率的な管理をおこなうためのヘルパー関数が必要になります。

double GetBalanceAtTime(datetime targetTime, double currentBalance)
{
   int historySize = ArraySize(balanceHistory);
   
   if(historySize == 0)
      return currentBalance;
   
   //--- if before first balance point, return initial deposit
   if(targetTime < balanceHistory[0].time)
      return initialDeposit;
   
   //--- find the most recent balance point before target time
   double balance = initialDeposit;
   
   for(int i = 0; i < historySize; i++)
   {
      if(balanceHistory[i].time <= targetTime)
         balance = balanceHistory[i].balance;
      else
         break;
   }
   
   return balance;
}

この関数はbalanceHistory配列を走査し、任意の時刻における残高がいくらであったかを特定します。残高はポジションの決済時にのみ変化するため、対象時刻以前または同時刻に発生した最新の残高ポイントを取得することになります。対象時刻より前に記録された残高変化が存在しない場合は、初期入金額を返します。これにより、残高曲線は階段状の形状となり、決済済みのポジション間では水平に維持される挙動が実現されます。

double CalculateFloatingPLAtTime(datetime barTime)
{
   double totalPL = 0;
   int positionsProcessed = 0;
   
   //--- iterate through all cached positions
   for(int i = 0; i < ArraySize(positionCache); i++)
   {
      //--- skip if position not yet opened at bar time
      if(positionCache[i].openTime > barTime)
         continue;
      
      //--- skip if position already closed at bar time
      if(positionCache[i].closeTime != 0 && positionCache[i].closeTime <= barTime)
         continue;
      
      //--- position was open at this bar time, calculate its P/L
      double priceAtTime = GetCachedPrice(positionCache[i].symbol, barTime);
      
      if(priceAtTime <= 0)
         continue;
      
      //--- calculate P/L using cached tick size and value
      if(positionCache[i].tickSize <= 0 || positionCache[i].tickValue <= 0)
         continue;
      
      double priceDiff = (positionCache[i].type == POSITION_TYPE_BUY) ?
                         priceAtTime - positionCache[i].openPrice :
                         positionCache[i].openPrice - priceAtTime;
      
      double positionPL = (priceDiff / positionCache[i].tickSize) * 
                          positionCache[i].tickValue * 
                          positionCache[i].volume;
      
      totalPL += positionPL;
      positionsProcessed++;
   }
   
   return totalPL;
}

これはインジケーターの計算の中核部分です。各ヒストリカルバーについて、追跡しているすべてのポジションを走査し、その時点でポジションがオープンしていたかどうかを判定します。ポジションは、openTimeがそのバーの時刻以前または同時刻であり、かつcloseTimeが未設定(未決済)であるか、あるいは closeTime がそのバーの時刻より後である場合にオープンと見なされます。各オープンポジションについて、そのバー時点の価格を取得し、含み損益を計算します。

損益計算は標準的な式に基づきます。価格差をtick sizeで割ることで変動ティック数を求め、それにtick valueを掛けることで1ロットあたりの損益を算出し、さらに取引数量(volume)を掛けることでポジション全体の損益が得られます。買いポジションでは現在価格からエントリー価格を引き、売りポジションではその逆を用います。これらすべてのオープンポジションの損益を合算することで、その時点における総含み損益が得られます。

double GetCachedPrice(string symbol, datetime barTime)
{
   //--- check cache first
   for(int i = 0; i < ArraySize(priceCache); i++)
   {
      if(priceCache[i].symbol == symbol && priceCache[i].time == barTime)
         return priceCache[i].price;
   }
   
   //--- not in cache, fetch price
   double price = 0;
   
   if(symbol == _Symbol)
   {
      //--- current chart symbol (fastest)
      int shift = iBarShift(_Symbol, Period(), barTime, true);
      if(shift >= 0)
         price = iClose(_Symbol, Period(), shift);
   }
   else
   {
      //--- other symbol, use CopyRates
      MqlRates rates[];
      ArraySetAsSeries(rates, true);
      int copied = CopyRates(symbol, Period(), barTime, 1, rates);
      
      if(copied > 0)
         price = rates[0].close;
   }
   
   //--- add to cache
   if(price > 0)
   {
      int cacheSize = ArraySize(priceCache);
      
      //--- limit cache size to prevent memory issues
      if(cacheSize >= 1000)
      {
         //--- clear oldest 500 entries
         ArrayRemove(priceCache, 0, 500);
         cacheSize = ArraySize(priceCache);
      }
      
      ArrayResize(priceCache, cacheSize + 1);
      priceCache[cacheSize].symbol = symbol;
      priceCache[cacheSize].time = barTime;
      priceCache[cacheSize].price = price;
   }
   
   return price;
}

これは、マルチ銘柄追跡を実現可能にするためのパフォーマンス最適化です。価格を取得する前に、そのデータがすでにキャッシュ内に存在するかどうかを確認します。キャッシュに存在する場合は即座にその値を返します。存在しない場合は、現在のチャート銘柄であればiBarShift()iClose()を使用し、それ以外の銘柄であればCopyRates()を用いて価格を取得します。iBarShift()関数は、指定した時刻に対応するバーのインデックスを特定し、CopyRates()は任意の銘柄の過去バー情報を取得します。

取得した価格はキャッシュに追加されます。このキャッシュは最大1000エントリのサイズ制限を持ち、上限を超えた場合はArrayRemove()を使用して古い500件を削除します。これによりメモリの増加を防ぎつつ、一般的な計算に必要なキャッシュ深度は維持されます。このキャッシュがなければ、インジケーターは大量のCopyRates()呼び出しを繰り返すことになり、深刻な遅延を引き起こす可能性があります。

void OnDeinit(const int reason)
{
   Comment("");
   ArrayFree(balanceHistory);
   ArrayFree(positionCache);
   ArrayFree(priceCache);
}

初期化解除関数は、インジケータが削除されたり再コンパイルされた際にクリーンアップ処理をおこないます。コメント領域をクリアし、すべての動的配列を解放します。MQL5ではメモリ管理は自動的におこなわれますが、特に開発中に頻繁に再読み込みされる可能性があるインジケーターでは、大きな配列を明示的に解放することは良いプラクティスです。



実例

読み込みと初期表示

このインジケーターを任意のチャートに適用すると、直ちに口座履歴の再構築が開始され、4本の曲線がサブウィンドウに描画されます。この処理は自動で実行され、オプションの入力パラメータ以外に追加の設定は必要ありません。インジケーターは、最初の入金以降の完全な取引履歴を表示し、現在表示している銘柄や時間足に関係なく口座全体の推移を再現します。

視覚的な階層は色分けによって各指標を区別します。灰色の破線は開始残高を表し、すべてのバーにおいて一定で推移する基準線として機能します。オレンジは実現済み残高を示し、ポジションが決済されたときのみ上下に変化します。青はエクイティを示し、残高に含み損益を加えた値として推移します。緑は含み損益そのものを別途プロットし、オープンポジションにおいてどの程度の資金がリスクにさらされているのか、またどの程度が確定済みなのかを視覚的に把握できるようにします。

図2:サブウィンドウ内に4本の曲線が色分け表示されたインジケーターの表示例

図2:インジケーターはサブウィンドウで同時に開始残高(灰色破線)、残高(オレンジ)、エクイティ(青)、含み損益(緑)を表示する


サブウィンドウはすべての曲線に合わせて自動的にスケーリングされますが、大きなドローダウン期間や積極的なポジションサイズ管理がおこなわれている場合には、含み損益の変動が表示領域を支配することがあります。その場合、サブウィンドウの境界をドラッグすることで高さを調整し、近接した曲線をより明確に識別することができます。

履歴検証とパターン分析

インジケーターの精度は、MetaTraderの口座履歴タブと照合することで検証できます。履歴内で最初の入金時刻を特定し、その日時までチャートを遡ると、開始残高ラインが残高およびエクイティの曲線と同一点で交差することが確認できます。この一致は、インジケーターが口座の起点を正しく特定し、適切な基準から履歴を再構築していることを示します。

図3:口座履歴タブとインジケーター曲線の相関を示す履歴検証例

図3:初回入金日時までチャートを遡ることで、開始残高ラインと実際の口座履歴が完全に一致し、履歴再構築の正確性が確認される


時間を進めながら履歴を観察すると、取引行動の特徴的なパターンが明らかになります。残高とエクイティが長期間一致して推移している場合は、ポジションを保有していないか、ほぼ損益が変動していない状態を示します。エクイティが残高を大きく上回る場合は、含み益のあるポジションを保有していることを意味します。逆にエクイティが残高を下回る場合は、含み損を抱えている状態です。これらの乖離の大きさは、現在どの程度のリスクを市場に投入しているかを示します。

残高曲線における急激な垂直変化は、ポジションの決済を示します。エクイティが残高より上にあった状態から残高がそれに追いつくように上昇した場合、それは利益確定を意味します。逆にエクイティが下回っている状態から残高が下落した場合は、損失の確定を意味します。含み損益曲線は未実現部分を明確に示し、ポジションがない場合はゼロ付近で推移し、ポジション保有中は上下に振動します。含み益をどの程度保持したまま決済しているかを観察することで、利益確定の傾向を分析できます。

これらの曲線の関係性はリスク管理の習慣も明らかにします。含み損が初期資金に近づく、あるいはそれを超える場合は、過度なレバレッジを使用している可能性があります。逆に残高とエクイティの乖離が小さい場合は、短期決済やタイトなストップロス運用をおこなっていることが考えられます。ナンピンをおこなうトレーダーでは、複数エントリーによりエクイティが残高より徐々に乖離し、最終決済で急激に収束する動きが観測されます。各口座は、このような統計的特徴を通じてそれぞれ異なるストーリーを示します。



結論

このインジケーターは、MetaTrader 5を外部サービスなしで完全な口座分析ツールへと変換します。取引履歴全体を再構築し、残高、エクイティ、含み損益を連続したラインとして表示することで、標準レポートでは見えないパターンを明らかにします。効率的なキャッシュ機構により、長期間かつマルチ銘柄の履歴であっても安定したパフォーマンスを維持します。

4本の曲線による可視化は、ドローダウンの深さ、利益確定の速度、そしてエクイティの成長の一貫性といったリスクプロファイルや取引行動を迅速に把握することを可能にします。これにより、推測ではなく実際の取引行動に基づいた意思決定が可能になります。

このインジケーターは追加設定を必要とせず、すべてのディールを自動的に処理し、銘柄を跨いでも正確性を維持します。曲線は取引の進行に応じて更新され、明確な統計的履歴を形成します。これにより、口座推移を検出し、戦略の改善と市場環境による影響を区別することが可能になります。

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

添付されたファイル |
最後のコメント | ディスカッションに移動 (3)
muhammad nasir
muhammad nasir | 30 1月 2026 において 10:38
常に市場を注視し、油断は禁物です。市場は刻一刻と変化しています。市場が最も低い水準にあるタイミングを見極めて買い時を判断しましょう。
Ahmad Katun
Ahmad Katun | 31 1月 2026 において 14:06
こんにちは。MQL5のトレーダーアカウントにアクセスしたいのですが。

Michael Charles Schefe
Michael Charles Schefe | 2 2月 2026 において 05:23

素晴らしい記事とインジケーターですね。これは残高+純資産追跡インジケーターに非常に近いものです。ただし、いくつかの重大な問題があるため、完全には正確ではありません。画像をご覧ください。

赤い縦線は、インジケーターを適用した時点を示しています。各ラインが本来あるべき位置より3,000以上も上方にずれています。つまり、右側のチャートでは、始値のローソク足に重なるラインの位置が正しいことがわかります。紫色のラインは、インジケーターの設定を変更するまでは正しく表示されていました。

紫色の線は、私がスニッピングツールで追加したものです。水色の線は、インジケーターの設定を変更するまでは正しく表示されていましたが、チャートが更新されると(下の画像の通り)、線が不正確になってしまいました。



初心者からエキスパートへ:流動性ゾーンインジケータの開発 初心者からエキスパートへ:流動性ゾーンインジケータの開発
流動性ゾーンの広がりとブレイクアウトレンジの大きさは、リテストが発生する確率に大きな影響を与える重要な変数です。本ディスカッションでは、これらの比率を組み込んだインジケータを開発するための完全なプロセスについて解説します。
初心者からエキスパートへ:流動性ベースの取引戦略の構築 初心者からエキスパートへ:流動性ベースの取引戦略の構築
流動性ゾーンは一般的に、価格がそのゾーンへ戻ってリテストするのを待つことで取引されます。この際、これらの領域内に指値注文を配置する手法がよく用いられます。本記事では、MQL5を用いてこのコンセプトを具体化し、こうしたゾーンをどのようにプログラム的に識別できるか、そしてリスク管理をどのように体系的に適用できるかを示します。流動性ベースの取引ロジックとその実装について、実践と理論の両面から解説していきます。
カスタムインジケータワークショップ(第1回):MQL5でSupertrendインジケータを構築する カスタムインジケータワークショップ(第1回):MQL5でSupertrendインジケータを構築する
MetaTrader 5向けに、第一原理から非リペイントのSupertrendを構築します。本実装では、ボラティリティ計算にiATRハンドルとCopyBufferを使用し、SetIndexBufferによってバッファをバインドします。また、プロット設定はPlotIndexSetIntegerを用いて構成し、DRAWCOLORCANDLESと2本のラインバンドによる描画をおこないます。ロジックは確定足のみに基づいて更新され、EMPTY_VALUE を使用して非アクティブなバンドを抑制します。さらに、atrPeriodおよびatrMultiplierの入力パラメータを公開し、調整可能な設計としています。これにより、戦略およびシグナル用途のために内部バッファが明確にドキュメント化された、クリーンでEA対応のオーバーレイ型インジケータが得られます。
プライスアクション分析ツールキットの開発(第58回):レンジ収縮分析および成熟度分類モジュール プライスアクション分析ツールキットの開発(第58回):レンジ収縮分析および成熟度分類モジュール
前回の記事で紹介した市場状態分類モジュールに続き、本稿ではコンプレッションゾーンの検出および評価をおこなうコアロジックの実装に焦点を当てます。本記事では、価格そのもののプライスアクションのみを用いて市場の持ち合い状態を分析する、レンジ収縮検出および成熟度評価システムをMQL5で実装する方法を解説します。