Ver en GitHub

Estrategia TradeEATemplateNews

Descripción general

La Estrategia TradeEATemplateNews es una conversión en C# del asesor experto de MetaTrader 4 "Trade EA Template for News". El sistema original pausaba el trading en torno a eventos económicos programados descargados de sitios web externos. Este port de StockSharp mantiene las ideas principales mientras las adapta a la API de alto nivel:

  • Usa velas completadas del marco temporal configurado (H1 por defecto).
  • Opera sólo cuando la cuenta está plana, exactamente como la plantilla MQL que requería cero órdenes abiertas.
  • Aplica una zona de silencio manual de noticias económicas que bloquea entradas antes y después de los eventos según su importancia.
  • Crea automáticamente brackets protectores de stop-loss y take-profit a 100 puntos del precio de ejecución (convertidos a través del paso del instrumento).

Lógica de trading

  1. Cada vela completada activa un recálculo del calendario de noticias. La estrategia almacena el precio de apertura de la vela anterior para que la siguiente barra pueda comparar su cierre con la apertura previa.
  2. Si el tiempo actual cae dentro de cualquier ventana de silencio configurada, la estrategia cancela órdenes pendientes y no abre nuevas operaciones.
  3. Cuando no hay posición abierta y el trading está permitido:
    • Se abre una posición larga si la última vela cierra por encima del precio de apertura de la vela anterior.
    • Se abre una posición corta si la última vela cierra por debajo del precio de apertura de la vela anterior.
  4. Los niveles de stop-loss y take-profit se expresan en puntos (TakeProfitPoints y StopLossPoints) y se convierten en desplazamientos de precio absolutos usando el valor Step del instrumento.

Calendario de noticias manual

El experto original descargaba datos de investing.com o DailyFX. Para portabilidad, la versión StockSharp espera un calendario curado manualmente suministrado a través del parámetro NewsEventsDefinition. El formato acepta una lista de entradas separadas por punto y coma o saltos de línea. Cada entrada debe contener al menos tres campos separados por comas:

AAAA-MM-DD HH:MM,DIVISAS,IMPORTANCIA[,TÍTULO]
  • AAAA-MM-DD HH:MM — inicio del evento en UTC. El parámetro opcional TimeZoneOffsetHours desplaza todos los tiempos parseados por la cantidad solicitada (por ejemplo, establece 3 para UTC+3).
  • DIVISAS — códigos de divisas o identificadores de instrumentos como USD, EUR, EUR/USD. Múltiples códigos pueden separarse con /, ,, ;, | o espacios.
  • IMPORTANCIA — palabra clave de importancia. Valores reconocidos: Low, Medium, Mid, Midle, Moderate, High, NFP, cadenas que contengan Nonfarm o Non-farm.
  • TÍTULO — descripción de texto libre opcional que se imprimirá en los mensajes de registro.

Ejemplo:

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

Ventanas de silencio

  • UseLowNews, UseMediumNews, UseHighNews y UseNfpNews alternan qué eventos se consideran.
  • LowMinutesBefore/After, MediumMinutesBefore/After, HighMinutesBefore/After y NfpMinutesBefore/After determinan cuántos minutos alrededor del evento se debe deshabilitar el trading.
  • OnlySymbolNews restringe el silencio a entradas cuyos códigos de divisas coincidan con el instrumento actual (por ejemplo, EURUSD resulta en el par {EUR, USD}). Desactívalo para pausar el trading en cada evento.
  • La estrategia mantiene sólo el evento de mayor importancia activo en cualquier momento. Los mensajes de registro informativos anuncian la razón del estado actual y la próxima publicación programada.

Parámetros

Parámetro Descripción Predeterminado
CandleType Tipo de datos de velas al que suscribirse. Por defecto 1 hora. 1h
UseLowNews Habilitar eventos de baja importancia. true
LowMinutesBefore / LowMinutesAfter Minutos antes/después de noticias de bajo impacto para bloquear entradas. 15 / 15
UseMediumNews Habilitar eventos de importancia media. true
MediumMinutesBefore / MediumMinutesAfter Minutos antes/después de noticias de impacto medio. 30 / 30
UseHighNews Habilitar eventos de alta importancia. true
HighMinutesBefore / HighMinutesAfter Minutos antes/después de noticias de alto impacto. 60 / 60
UseNfpNews Habilitar el indicador de Non-farm Payrolls. true
NfpMinutesBefore / NfpMinutesAfter Minutos antes/después de eventos NFP. 180 / 180
OnlySymbolNews Filtrar el calendario por los códigos de divisas del instrumento actual. true
NewsEventsDefinition Cadena de descripción del calendario económico manual. vacío
TimeZoneOffsetHours Desplazamiento aplicado a cada evento parseado (UTC por defecto). 0
TakeProfitPoints Distancia en puntos para la orden protectora de take-profit. 100
StopLossPoints Distancia en puntos para la orden protectora de stop-loss. 100

Volume se hereda de Strategy y debe establecerse según el tamaño de posición deseado.

Diferencias con la versión MQL

  • Sin descarga HTTP automática — el usuario suministra la lista de noticias manualmente, lo que evita dependencias externas y mantiene la conversión determinista.
  • Las etiquetas de gráfico y líneas verticales se reemplazan con mensajes de registro que describen el evento activo o próximo.
  • El experto MQL abría órdenes con tamaño de lote fijo 0.01; en StockSharp el tamaño de posición proviene de la propiedad Volume.
  • Toda la lógica se implementa con la API de suscripción de velas de alto nivel preservando el comportamiento consciente de noticias de la plantilla.

Notas de despliegue

  1. Llena NewsEventsDefinition antes de iniciar la estrategia o actualízalo, detén y reinicia para recargar el calendario.
  2. Ajusta TimeZoneOffsetHours y los parámetros de minutos antes/después para que coincidan con tu sesión de trading.
  3. Configura Volume, portafolio e instrumento en la interfaz o en código, luego inicia la estrategia.
  4. Observa el registro de la estrategia para mensajes como "Trading paused due to high news" o "Next scheduled news" para confirmar la lógica de silencio.

La traducción a Python se omite intencionalmente según lo solicitado.

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