Открыть на GitHub

Стратегия Trade EA Template for News

Общее описание

Trade EA Template for News Strategy — это порт на C# экспертного советника MetaTrader 4 «Trade EA Template for News». Оригинальная версия отключала торговлю перед и после выхода макроэкономических новостей, которые подгружались с внешних сайтов. Реализация для StockSharp сохраняет идею фильтра новостей и переносит её на высокоуровневый API:

  • Работает с завершёнными свечами выбранного таймфрейма (по умолчанию H1).
  • Открывает позиции только при отсутствии активных сделок, что полностью повторяет условие OrdersTotal()<1 из MQL.
  • Блокирует открытия сделок в заданные интервалы до и после новостей в зависимости от их важности.
  • Автоматически ставит защитные стоп-лосс и тейк-профит на расстоянии 100 пунктов, рассчитанных через шаг цены инструмента.

Логика торговли

  1. На каждой закрытой свече стратегия пересчитывает расписание новостей. Открытие предыдущей свечи сохраняется, чтобы следующая свеча могла сравнить свой закрывающийся уровень с прошлым открытием.
  2. Если текущее время попадает в любой запретный интервал, все заявки отменяются, а новые сделки не открываются.
  3. При отсутствии позиции и разрешённой торговле:
    • Покупка открывается, если закрытие последней свечи выше открытия предыдущей свечи.
    • Продажа открывается, если закрытие последней свечи ниже открытия предыдущей свечи.
  4. Параметры TakeProfitPoints и StopLossPoints задают расстояние до защитных ордеров в пунктах; фактический ценовой сдвиг рассчитывается умножением на Security.Step.

Ручное расписание новостей

Вместо автоматического скачивания календаря необходимо вручную заполнить параметр NewsEventsDefinition. В нём перечисляются события, разделённые точкой с запятой или переводом строки. Каждая запись должна содержать минимум три поля, разделённые запятыми:

ГГГГ-ММ-ДД ЧЧ:ММ,ВАЛЮТЫ,ВАЖНОСТЬ[,НАЗВАНИЕ]
  • ГГГГ-ММ-ДД ЧЧ:ММ — время события в UTC. Параметр TimeZoneOffsetHours сдвигает все события на указанное количество часов (например, 3 для UTC+3).
  • ВАЛЮТЫ — коды валют или обозначения инструментов (USD, EUR, EUR/USD). Несколько кодов можно разделять через /, ,, ;, | или пробелы.
  • ВАЖНОСТЬ — ключевое слово: поддерживаются Low, Medium, Mid, Midle, Moderate, High, NFP, а также любые строки, содержащие Nonfarm или Non-farm.
  • НАЗВАНИЕ — необязательное описание, которое будет отображаться в логах.

Пример:

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

Настройка запретных интервалов

  • Флаги UseLowNews, UseMediumNews, UseHighNews, UseNfpNews включают или выключают соответствующие группы событий.
  • Пары параметров LowMinutesBefore/After, MediumMinutesBefore/After, HighMinutesBefore/After, NfpMinutesBefore/After задают длительность запрета до и после новости.
  • Параметр OnlySymbolNews ограничивает фильтр событиями, содержащими валюты из тикера текущего инструмента (например, EURUSD{EUR, USD}). При отключении блокируется торговля по всем событиям.
  • Если одновременно действует несколько новостей, приоритет имеет событие с наибольшей важностью. В лог выводятся сообщения с указанием причины блокировки и ближайшего релиза.

Параметры

Параметр Описание Значение по умолчанию
CandleType Тип свечей для подписки (по умолчанию часовые). 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

Значение Volume наследуется от базового класса Strategy и определяет объём позиции.

Отличия от MQL-версии

  • Нет автоматического веб-запроса — расписание формируется вручную, что делает стратегию автономной и воспроизводимой.
  • Визуальные линии и подписи заменены логами вида «Trading paused due to…» и «Next scheduled news…».
  • В MQL фиксированный объём 0.01 лота, в StockSharp объём задаётся через Volume.
  • Вся логика построена на высокоуровневой подписке свечей без прямых обращений к индикаторам.

Рекомендации по запуску

  1. Заполните NewsEventsDefinition перед стартом (после изменения параметра перезапустите стратегию, чтобы перечитать список).
  2. Настройте TimeZoneOffsetHours и длительность окон в соответствии с вашим торговым расписанием.
  3. Установите Volume, выберите портфель и инструмент, затем запустите стратегию.
  4. Контролируйте журнал стратегии — там появятся сообщения о причинах паузы и времени ближайшей новости.

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
	}
}