GitHub で見る

TradeEATemplateNews 戦略

概要

TradeEATemplateNews 戦略は、MetaTrader 4 エキスパートアドバイザー "Trade EA Template for News" の C# 変換です。元のシステムは外部ウェブサイトからダウンロードされた予定された経済イベントの前後に取引を一時停止していました。この StockSharp ポートはコアのアイデアを維持しながらハイレベル API に適応させます:

  • 設定された時間軸(デフォルトは H1)の完了したローソク足を使用します。
  • 口座がフラットなときにのみ取引し、ゼロのオープン注文を必要とした MQL テンプレートとまったく同じです。
  • 重要度に応じてイベントの前後のエントリーをブロックする手動の経済ニュースブラックアウトを適用します。
  • 執行価格から 100 ポイント離れた保護的なストップロスとテイクプロフィットブラケットを自動作成します(銘柄ステップを通じて変換)。

トレーディングロジック

  1. 各完了したローソク足がニューススケジュールの再計算をトリガーします。戦略は次のバーが前の始値に対して終値を比較できるように、前のローソク足の始値を保存します。
  2. 現在時刻が設定されたブラックアウトウィンドウ内に入った場合、戦略は保留中の注文をキャンセルし、新しい取引を開きません。
  3. ポジションが開いておらず、取引が許可されている場合:
    • 最後のローソク足が前のローソク足の始値より高く閉じた場合、ロングポジションが開かれます。
    • 最後のローソク足が前のローソク足の始値より低く閉じた場合、ショートポジションが開かれます。
  4. ストップロスとテイクプロフィットのレベルはポイントで表現され(TakeProfitPointsStopLossPoints)、銘柄の Step 値を使用して絶対価格オフセットに変換されます。

手動ニューススケジュール

元のエキスパートは investing.com または DailyFX からデータをダウンロードしていました。移植性のため、StockSharp バージョンは NewsEventsDefinition パラメーターを通じて提供される手動でキュレーションされたカレンダーを期待します。フォーマットはセミコロンまたは改行で区切られたエントリーのリストを受け入れます。各エントリーには少なくとも3つのカンマ区切りフィールドが含まれている必要があります:

YYYY-MM-DD HH:MM,通貨,重要度[,タイトル]
  • YYYY-MM-DD HH:MM — UTC でのイベント開始。オプションの TimeZoneOffsetHours パラメーターはすべての解析された時刻を要求された量だけシフトします(例えば UTC+3 には 3 を設定)。
  • 通貨USDEUREUR/USD などの通貨コードまたはインストゥルメント識別子。複数のコードは /,;| またはスペースで区切れます。
  • 重要度 — 重要度キーワード。認識される値:LowMediumMidMidleModerateHighNFPNonfarm または Non-farm を含む文字列。
  • タイトル — ログメッセージに印刷されるオプションのフリーテキスト説明。

例:

2024-03-01 13:30,USD,High,Nonfarm Payrolls;2024-03-01 15:00,USD,Low,Factory Orders

ブラックアウトウィンドウ

  • UseLowNewsUseMediumNewsUseHighNewsUseNfpNews がどのイベントを考慮するかを切り替えます。
  • LowMinutesBefore/AfterMediumMinutesBefore/AfterHighMinutesBefore/AfterNfpMinutesBefore/After がイベント周囲で取引を無効にする分数を決定します。
  • OnlySymbolNews は現在の銘柄の通貨コードと一致するエントリーのみにブラックアウトを制限します(例:EURUSD{EUR, USD} のペアになります)。すべてのイベントで取引を一時停止するには無効にします。
  • 戦略は任意の時点で最も高い重要度のイベントのみをアクティブに保ちます。情報ログメッセージが現在の状態の理由と次の予定されたリリースを知らせます。

パラメーター

パラメーター 説明 デフォルト
CandleType サブスクライブするローソク足データタイプ。デフォルトは1時間。 1h
UseLowNews 低重要度のイベントを有効にします。 true
LowMinutesBefore / LowMinutesAfter エントリーをブロックする低インパクトニュースの前/後の分数。 15 / 15
UseMediumNews 中程度の重要度のイベントを有効にします。 true
MediumMinutesBefore / MediumMinutesAfter 中インパクトニュースの前/後の分数。 30 / 30
UseHighNews 高重要度のイベントを有効にします。 true
HighMinutesBefore / HighMinutesAfter 高インパクトニュースの前/後の分数。 60 / 60
UseNfpNews Non-farm Payrolls フラグを有効にします。 true
NfpMinutesBefore / NfpMinutesAfter NFP イベントの前/後の分数。 180 / 180
OnlySymbolNews 現在の銘柄の通貨コードでカレンダーをフィルタリングします。 true
NewsEventsDefinition 手動経済カレンダー説明文字列。
TimeZoneOffsetHours 各解析されたイベントに適用されるオフセット(デフォルトでは UTC)。 0
TakeProfitPoints 保護的テイクプロフィット注文のポイント距離。 100
StopLossPoints 保護的ストップロス注文のポイント距離。 100

VolumeStrategy から継承され、希望するポジションサイズに従って設定する必要があります。

MQL バージョンとの違い

  • 自動 HTTP ダウンロードなし — ユーザーがニュースリストを手動で提供し、外部依存関係を回避して変換を確定的に保ちます。
  • チャートラベルと垂直線はアクティブまたは予定されたイベントを説明するログメッセージに置き換えられます。
  • MQL エキスパートは固定ロットサイズ 0.01 で注文を開いていました;StockSharp ではポジションサイズは Volume プロパティから来ます。
  • テンプレートのニュース対応動作を維持しながら、すべてのロジックがハイレベルローソク足サブスクリプション API で実装されています。

デプロイメントの注意事項

  1. 戦略を開始する前に NewsEventsDefinition を入力するか、更新して停止・再起動してスケジュールを再ロードします。
  2. TimeZoneOffsetHours と分前/後パラメーターを取引セッションに合わせて調整します。
  3. UI またはコードで Volume、ポートフォリオ、銘柄を設定してから戦略を開始します。
  4. ブラックアウトロジックを確認するために、"Trading paused due to high news" または "Next scheduled news" などのメッセージについて戦略ログを確認します。

Python 翻訳はリクエスト通り意図的に省略されています。

using System;
using System.Linq;
using System.Collections.Generic;

using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;

using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;

using System.Globalization;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Trade EA Template for News strategy converted from MQL.
/// </summary>
public class TradeEaTemplateForNewsStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<bool> _useLowNews;
	private readonly StrategyParam<int> _lowMinutesBefore;
	private readonly StrategyParam<int> _lowMinutesAfter;
	private readonly StrategyParam<bool> _useMediumNews;
	private readonly StrategyParam<int> _mediumMinutesBefore;
	private readonly StrategyParam<int> _mediumMinutesAfter;
	private readonly StrategyParam<bool> _useHighNews;
	private readonly StrategyParam<int> _highMinutesBefore;
	private readonly StrategyParam<int> _highMinutesAfter;
	private readonly StrategyParam<bool> _useNfpNews;
	private readonly StrategyParam<int> _nfpMinutesBefore;
	private readonly StrategyParam<int> _nfpMinutesAfter;
	private readonly StrategyParam<bool> _onlySymbolNews;
	private readonly StrategyParam<string> _newsEventsDefinition;
	private readonly StrategyParam<int> _timeZoneOffsetHours;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _stopLossPoints;

	private readonly List<NewsEvent> _newsEvents = new();
	private readonly HashSet<string> _instrumentCurrencies = new(StringComparer.OrdinalIgnoreCase);

	private decimal? _previousOpenPrice;
	private bool _newsBlocking;
	private string _lastNewsMessage = string.Empty;

	public TradeEaTemplateForNewsStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame());
		_useLowNews = Param(nameof(UseLowNews), true);
		_lowMinutesBefore = Param(nameof(LowMinutesBefore), 15);
		_lowMinutesAfter = Param(nameof(LowMinutesAfter), 15);
		_useMediumNews = Param(nameof(UseMediumNews), true);
		_mediumMinutesBefore = Param(nameof(MediumMinutesBefore), 30);
		_mediumMinutesAfter = Param(nameof(MediumMinutesAfter), 30);
		_useHighNews = Param(nameof(UseHighNews), true);
		_highMinutesBefore = Param(nameof(HighMinutesBefore), 60);
		_highMinutesAfter = Param(nameof(HighMinutesAfter), 60);
		_useNfpNews = Param(nameof(UseNfpNews), true);
		_nfpMinutesBefore = Param(nameof(NfpMinutesBefore), 180);
		_nfpMinutesAfter = Param(nameof(NfpMinutesAfter), 180);
		_onlySymbolNews = Param(nameof(OnlySymbolNews), true);
		_newsEventsDefinition = Param(nameof(NewsEventsDefinition), string.Empty);
		_timeZoneOffsetHours = Param(nameof(TimeZoneOffsetHours), 0);
		_takeProfitPoints = Param(nameof(TakeProfitPoints), 100);
		_stopLossPoints = Param(nameof(StopLossPoints), 100);
	}

	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	public bool UseLowNews
	{
		get => _useLowNews.Value;
		set => _useLowNews.Value = value;
	}

	public int LowMinutesBefore
	{
		get => _lowMinutesBefore.Value;
		set => _lowMinutesBefore.Value = value;
	}

	public int LowMinutesAfter
	{
		get => _lowMinutesAfter.Value;
		set => _lowMinutesAfter.Value = value;
	}

	public bool UseMediumNews
	{
		get => _useMediumNews.Value;
		set => _useMediumNews.Value = value;
	}

	public int MediumMinutesBefore
	{
		get => _mediumMinutesBefore.Value;
		set => _mediumMinutesBefore.Value = value;
	}

	public int MediumMinutesAfter
	{
		get => _mediumMinutesAfter.Value;
		set => _mediumMinutesAfter.Value = value;
	}

	public bool UseHighNews
	{
		get => _useHighNews.Value;
		set => _useHighNews.Value = value;
	}

	public int HighMinutesBefore
	{
		get => _highMinutesBefore.Value;
		set => _highMinutesBefore.Value = value;
	}

	public int HighMinutesAfter
	{
		get => _highMinutesAfter.Value;
		set => _highMinutesAfter.Value = value;
	}

	public bool UseNfpNews
	{
		get => _useNfpNews.Value;
		set => _useNfpNews.Value = value;
	}

	public int NfpMinutesBefore
	{
		get => _nfpMinutesBefore.Value;
		set => _nfpMinutesBefore.Value = value;
	}

	public int NfpMinutesAfter
	{
		get => _nfpMinutesAfter.Value;
		set => _nfpMinutesAfter.Value = value;
	}

	public bool OnlySymbolNews
	{
		get => _onlySymbolNews.Value;
		set => _onlySymbolNews.Value = value;
	}

	public string NewsEventsDefinition
	{
		get => _newsEventsDefinition.Value;
		set => _newsEventsDefinition.Value = value;
	}

	public int TimeZoneOffsetHours
	{
		get => _timeZoneOffsetHours.Value;
		set => _timeZoneOffsetHours.Value = value;
	}

	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	private bool HasNewsFilter => UseLowNews || UseMediumNews || UseHighNews || UseNfpNews;

	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	=> [(Security, CandleType)];

	protected override void OnReseted()
	{
		base.OnReseted();

		_previousOpenPrice = null;
		_newsEvents.Clear();
		_newsBlocking = false;
		_lastNewsMessage = string.Empty;
		_instrumentCurrencies.Clear();
	}

	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		BuildInstrumentCurrencies();
		ParseNewsEvents();
		ConfigureProtection();

		var subscription = SubscribeCandles(CandleType);

		subscription
			.Bind(ProcessCandle)
			.Start();
	}

	private void ConfigureProtection()
	{
		// Configure stop-loss and take-profit to mirror the 100 point brackets from the template EA.
		var step = Security?.PriceStep ?? 0m;

		if (step <= 0m)
		{
			LogWarning("Security step is zero. Protective orders cannot be configured.");
			return;
		}

		var takeUnit = TakeProfitPoints > 0 ? new Unit(step * TakeProfitPoints, UnitTypes.Absolute) : new Unit();
		var stopUnit = StopLossPoints > 0 ? new Unit(step * StopLossPoints, UnitTypes.Absolute) : new Unit();

		if (TakeProfitPoints > 0 || StopLossPoints > 0)
			StartProtection(takeUnit, stopUnit);
	}

	private void ProcessCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;

		UpdateNewsState(candle.CloseTime);

		// No bound indicators to check readiness for.

		// Abort any signals while a news blackout is active.
		if (_newsBlocking)
		{
			return;
		}

		if (_previousOpenPrice == null)
		{
			// Store the first open price so the next candle can compare against it.
			_previousOpenPrice = candle.OpenPrice;
			return;
		}

		var previousOpen = _previousOpenPrice.Value;
		_previousOpenPrice = candle.OpenPrice;

		if (Volume <= 0)
			return;

		if (Position != 0)
		{
			// The original template trades only when there are no existing positions.
			return;
		}

		if (candle.ClosePrice > previousOpen)
		{
			BuyMarket();
			LogInfo($"Long entry after bullish close at {candle.ClosePrice} compared to prior open {previousOpen}.");
		}
		else if (candle.ClosePrice < previousOpen)
		{
			SellMarket();
			LogInfo($"Short entry after bearish close at {candle.ClosePrice} compared to prior open {previousOpen}.");
		}
	}

	private void UpdateNewsState(DateTimeOffset currentTime)
	{
		// Without configured events the strategy should allow trading freely.
		if (!HasNewsFilter || _newsEvents.Count == 0)
		{
			if (_newsBlocking)
			{
				_newsBlocking = false;
				NotifyNewsMessage("No upcoming news events.");
			}
			return;
		}

		NewsEvent blockingEvent = null;
		NewsEvent upcomingEvent = null;

		for (var i = 0; i < _newsEvents.Count; i++)
		{
			var evt = _newsEvents[i];

			if (!IsImportanceEnabled(evt.Importance))
				continue;

			if (!MatchesSecurity(evt))
				continue;

			if (IsInsideWindow(evt, currentTime))
			{
				if (blockingEvent == null || evt.Importance > blockingEvent.Importance)
					blockingEvent = evt;
			}
			else if (evt.Time > currentTime)
			{
				if (upcomingEvent == null || evt.Time < upcomingEvent.Time)
					upcomingEvent = evt;
			}
		}

		var wasBlocking = _newsBlocking;
		_newsBlocking = blockingEvent != null;

		NotifyNewsMessage(BuildNewsMessage(blockingEvent, upcomingEvent));

		if (_newsBlocking && !wasBlocking)
			CancelActiveOrders();
	}

	private void NotifyNewsMessage(string message)
	{
		if (_lastNewsMessage.EqualsIgnoreCase(message))
			return;

		_lastNewsMessage = message;
		LogInfo(message);
	}

	private bool IsImportanceEnabled(NewsImportances importance)
	=> importance switch
	{
		NewsImportances.Low => UseLowNews,
		NewsImportances.Medium => UseMediumNews,
		NewsImportances.High => UseHighNews,
		NewsImportances.Nfp => UseNfpNews,
		_ => false
	};

	private bool IsInsideWindow(NewsEvent evt, DateTimeOffset currentTime)
	{
		var before = TimeSpan.FromMinutes(GetMinutesBefore(evt.Importance));
		var after = TimeSpan.FromMinutes(GetMinutesAfter(evt.Importance));
		var start = evt.Time - before;
		var end = evt.Time + after;
		return currentTime >= start && currentTime <= end;
	}

	private int GetMinutesBefore(NewsImportances importance)
	=> importance switch
	{
		NewsImportances.Low => Math.Max(0, LowMinutesBefore),
		NewsImportances.Medium => Math.Max(0, MediumMinutesBefore),
		NewsImportances.High => Math.Max(0, HighMinutesBefore),
		NewsImportances.Nfp => Math.Max(0, NfpMinutesBefore),
		_ => 0
	};

	private int GetMinutesAfter(NewsImportances importance)
	=> importance switch
	{
		NewsImportances.Low => Math.Max(0, LowMinutesAfter),
		NewsImportances.Medium => Math.Max(0, MediumMinutesAfter),
		NewsImportances.High => Math.Max(0, HighMinutesAfter),
		NewsImportances.Nfp => Math.Max(0, NfpMinutesAfter),
		_ => 0
	};

	private string BuildNewsMessage(NewsEvent activeEvent, NewsEvent upcomingEvent)
	{
		if (activeEvent != null)
		{
			var label = GetImportanceLabel(activeEvent.Importance);
			var timeText = activeEvent.Time.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
			var currencyPart = activeEvent.Currency.IsEmptyOrWhiteSpace() ? string.Empty : $" [{activeEvent.Currency}]";
			var titlePart = activeEvent.Title.IsEmptyOrWhiteSpace() ? string.Empty : $" - {activeEvent.Title}";
			return $"Trading paused due to {label} news{currencyPart} at {timeText}{titlePart}.";
		}

		if (upcomingEvent != null)
		{
			var label = GetImportanceLabel(upcomingEvent.Importance);
			var timeText = upcomingEvent.Time.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
			var currencyPart = upcomingEvent.Currency.IsEmptyOrWhiteSpace() ? string.Empty : $" [{upcomingEvent.Currency}]";
			var titlePart = upcomingEvent.Title.IsEmptyOrWhiteSpace() ? string.Empty : $" - {upcomingEvent.Title}";
			return $"Next scheduled news: {label}{currencyPart} at {timeText}{titlePart}.";
		}

		return "No upcoming news events.";
	}

	private static string GetImportanceLabel(NewsImportances importance)
	=> importance switch
	{
		NewsImportances.Low => "low",
		NewsImportances.Medium => "medium",
		NewsImportances.High => "high",
		NewsImportances.Nfp => "non-farm payroll",
		_ => "unknown"
	};

	private void ParseNewsEvents()
	{
		// Parse the manual economic calendar description provided in the parameters.
		_newsEvents.Clear();

		var raw = NewsEventsDefinition;

		if (raw.IsEmptyOrWhiteSpace())
		{
			LogInfo("News events list is empty. The filter will allow trading at all times.");
			return;
		}

		var separators = new[] { ';', '\n', '\r' };
		var entries = raw.Split(separators, StringSplitOptions.RemoveEmptyEntries);

		for (var entryIndex = 0; entryIndex < entries.Length; entryIndex++)
		{
			var entry = entries[entryIndex].Trim();

			if (entry.Length == 0)
				continue;

			var rawParts = entry.Split(',');

			if (rawParts.Length < 3)
			{
				LogWarning($"Unable to parse news entry '{entry}'. Expected at least time, currency and importance.");
				continue;
			}

			var parts = new string[rawParts.Length];
			for (var i = 0; i < rawParts.Length; i++)
				parts[i] = rawParts[i].Trim();

			if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var time))
			{
				LogWarning($"Unable to parse time '{parts[0]}' in news entry '{entry}'.");
				continue;
			}

			var currencies = parts[1].ToUpperInvariant();

			if (!TryParseImportance(parts[2], out var importance))
			{
				LogWarning($"Unable to parse importance '{parts[2]}' in news entry '{entry}'.");
				continue;
			}

			var title = string.Empty;
			if (parts.Length > 3)
			{
				var count = parts.Length - 3;
				var combined = string.Join(",", parts, 3, count);
				title = combined.Trim();
			}

			time = time.ToOffset(TimeSpan.FromHours(TimeZoneOffsetHours));

			_newsEvents.Add(new NewsEvent(time, currencies, importance, title));
		}

		_newsEvents.Sort((left, right) => left.Time.CompareTo(right.Time));

		if (_newsEvents.Count > 0)
			LogInfo($"Loaded {_newsEvents.Count} manual news event(s).");
		else
			LogInfo("No valid news events parsed. The filter will remain inactive.");
	}

	private static bool TryParseImportance(string value, out NewsImportances importance)
	{
		if (value.IsEmptyOrWhiteSpace())
		{
			importance = default;
			return false;
		}

		var normalized = value.Trim();

		if (normalized.Equals("LOW", StringComparison.OrdinalIgnoreCase))
		{
			importance = NewsImportances.Low;
			return true;
		}

		if (normalized.Equals("MEDIUM", StringComparison.OrdinalIgnoreCase) ||
				normalized.Equals("MID", StringComparison.OrdinalIgnoreCase) ||
				normalized.Equals("MIDLE", StringComparison.OrdinalIgnoreCase) ||
				normalized.Equals("MODERATE", StringComparison.OrdinalIgnoreCase))
		{
			importance = NewsImportances.Medium;
			return true;
		}

		if (normalized.Equals("HIGH", StringComparison.OrdinalIgnoreCase))
		{
			importance = NewsImportances.High;
			return true;
		}

		if (normalized.Equals("NFP", StringComparison.OrdinalIgnoreCase) ||
				normalized.Contains("NONFARM", StringComparison.OrdinalIgnoreCase) ||
				normalized.Contains("NON-FARM", StringComparison.OrdinalIgnoreCase))
		{
			importance = NewsImportances.Nfp;
			return true;
		}

		importance = default;
		return false;
	}

	private bool MatchesSecurity(NewsEvent evt)
	{
		if (!OnlySymbolNews)
			return true;

		// Match the configured currencies against the current instrument if required.

		if (_instrumentCurrencies.Count == 0)
			return true;

		if (evt.Currency.IsEmptyOrWhiteSpace())
			return true;

		var separators = new[] { '/', ',', '|', ';', ' ' };
		var tokens = evt.Currency.Split(separators, StringSplitOptions.RemoveEmptyEntries);

		for (var i = 0; i < tokens.Length; i++)
		{
			var token = tokens[i].Trim();
			if (token.Length == 0)
				continue;

			if (_instrumentCurrencies.Contains(token))
				return true;
		}

		return false;
	}

	private void BuildInstrumentCurrencies()
	{
		// Extract major currency codes from the security symbol (e.g., EURUSD -> EUR, USD).
		_instrumentCurrencies.Clear();

		var code = Security?.Code;

		if (code.IsEmptyOrWhiteSpace())
			return;

		var trimmed = code.Trim().ToUpperInvariant();

		if (trimmed.Length >= 6)
		{
			_instrumentCurrencies.Add(trimmed.Substring(0, 3));
			_instrumentCurrencies.Add(trimmed.Substring(trimmed.Length - 3, 3));
		}
		else
		{
			_instrumentCurrencies.Add(trimmed);
		}
	}

	private sealed record class NewsEvent(DateTimeOffset Time, string Currency, NewsImportances Importance, string Title);

	private enum NewsImportances
	{
		Low = 1,
		Medium = 2,
		High = 3,
		Nfp = 4
	}
}