Ver en GitHub

Estrategia de seguimiento de rango

Descripción general

La estrategia Range Follower reproduce el MetaTrader 5 asesor experto "Range Follower" utilizando la API de alto nivel de StockSharp. Supervisa el rango de precios del día actual en relación con un punto de referencia diario de rango verdadero promedio (ATR) y abre una única operación de ruptura cuando el precio se aleja lo suficiente del máximo o mínimo de la sesión. La conversión mantiene el enfoque original de dividir el ATR en una porción de activación y una porción residual que se convierte en la distancia de obtención de ganancias.

Lógica de trading

  1. Línea base de volatilidad diaria
    • Un ATR de 20 períodos calculado sobre velas diarias proporciona el rango de referencia para el día de negociación actual.
    • El valor ATR se divide por TriggerPercent en dos segmentos: la distancia de activación que se debe exceder antes de ingresar y la distancia restante que se utiliza como objetivo de ganancias.
  2. Seguimiento de alcance
    • La estrategia registra continuamente los máximos y mínimos de la sesión actual de la vela diaria activa.
    • Las actualizaciones de Nivel 1 proporcionan los últimos mejores precios de oferta y mejor demanda que se utilizan para medir la distancia desde las cotizaciones actuales hasta los extremos de la sesión.
  3. Entrada única por día
    • Cuando la mejor oferta supera la distancia de activación por encima del mínimo de la sesión y aún no se ha abierto ninguna operación, la estrategia compra en el mercado.
    • Cuando la mejor demanda es mayor que la distancia de activación por debajo del máximo de la sesión y aún no se ha abierto ninguna operación, la estrategia vende al mercado.
    • Sólo se permite una operación por día; la bandera se restablece cuando comienza una nueva sesión.
  4. Detener pérdidas y tomar ganancias
    • Para posiciones largas, el stop-loss se coloca una distancia de activación por debajo del precio de entrada y el take-profit una distancia residual por encima de este.
    • Para posiciones cortas, el stop-loss está una distancia de activación por encima del precio de entrada y el take-profit una distancia residual por debajo de él.
    • El seguimiento de precios se realiza tanto en los ticks de Nivel 1 como en las actualizaciones de velas para cerrar posiciones tan pronto como se supera un nivel.
  5. Restablecimiento de sesión diaria
    • En la primera vela de un nuevo día de negociación, la estrategia cierra cualquier posición abierta, borra el estado interno y recarga la línea de base ATR.
    • Si el rango diario actual ya excede la distancia de activación cuando se inicializa la sesión, se omite la negociación durante el resto del día para imitar la verificación de seguridad del EA original.

Parámetros

Nombre Predeterminado Descripción
CandleType velas de 15 minutos Horario de trabajo utilizado para detectar límites de sesión.
TriggerPercent 60 Porcentaje del ATR diario utilizado como distancia de activación de ruptura. Debe permanecer entre 10 y 90.
Volume 0.1 Volumen de órdenes de mercado para entradas largas y cortas.

Gestión del riesgo

  • Las paradas y los objetivos se derivan de la misma línea base ATR de modo que la relación recompensa-riesgo siempre sea igual a (100 - TriggerPercent) : TriggerPercent.
  • La estrategia registra una única posición a la vez y la liquida inmediatamente cuando se toca el stop o el objetivo, evitando múltiples operaciones superpuestas.
  • StartProtection() habilita la infraestructura de protección de StockSharp, lo que permite que los componentes externos conecten topes dinámicos o protectores de cartera si es necesario.

Notas de implementación

  • Los valores diarios ATR se producen mediante una suscripción de vela diaria dedicada y el indicador AverageTrueRange vinculado a través del nivel alto API.
  • Los datos de nivel 1 son necesarios para reflejar las decisiones basadas en ticks de EA; Los mejores precios de oferta y demanda impulsan tanto las comprobaciones de entrada como de salida.
  • Los límites de las sesiones diarias se derivan de las velas del período de trabajo, lo que garantiza que cualquier calendario comercial utilizado en StockSharp restablecerá la estrategia de manera consistente.
  • La conversión evita búferes de indicadores manuales o bucles históricos y, en cambio, se basa en campos con estado actualizados por las devoluciones de llamada Bind.
using System;
using System.Collections.Generic;

using Ecng.Common;

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

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Daily ATR range follower using live best bid/ask and one entry per session.
/// </summary>
public class RangeFollowerStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _triggerPercent;

	private decimal? _dailyAtr;
	private decimal? _sessionAtr;
	private decimal? _bestBid;
	private decimal? _bestAsk;
	private DateTime _sessionDate;
	private decimal _sessionHigh;
	private decimal _sessionLow;
	private bool _tradedToday;
	private bool _skipToday;
	private decimal? _stopPrice;
	private decimal? _takePrice;
	private Unit _takeDistance;
	private Unit _stopDistance;
	private Order _entryOrder;
	private Order _exitOrder;
	private Sides _entrySide;
	private decimal _entryVolume;
	private decimal _entryValue;

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

	public RangeFollowerStrategy()
	{
		Volume = 0.1m;
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
			.SetDisplay("Candle Type", "Working timeframe used for session boundaries.", "General");
		_triggerPercent = Param(nameof(TriggerPercent), 60m)
			.SetRange(10m, 90m)
			.SetDisplay("Trigger Percent", "Percentage of daily ATR used as the entry/stop distance.", "Signal");
		Trades.TradeAdded += ProcessTrade;
		OrderRegistering += order =>
		{
			if (Position != 0m && order.Side == (Position > 0m ? Sides.Sell : Sides.Buy))
				_exitOrder = order;
		};
	}

	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
		=> [(Security, CandleType), (Security, TimeSpan.FromDays(1).TimeFrame()), (Security, DataType.Level1)];

	protected override void OnReseted()
	{
		base.OnReseted();
		_dailyAtr = null;
		_bestBid = null;
		_bestAsk = null;
		_takeDistance = _stopDistance = null;
		_entryOrder = _exitOrder = null;
		_entrySide = default;
		_entryVolume = _entryValue = 0m;
		ResetSession(default);
	}

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

		var atr = new AverageTrueRange { Length = 20 };
		SubscribeCandles(TimeSpan.FromDays(1).TimeFrame())
			.BindEx(atr, ProcessDailyCandle)
			.Start();

		foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
		{
			var quotes = new Subscription(DataType.Level1, Security);
			quotes.MarketData.BuildField = field;
			SubscribeLevel1(quotes).Bind(ProcessLevel1).Start();
		}

		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(ProcessWorkingCandle).Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

	private void ProcessDailyCandle(ICandleMessage candle, IIndicatorValue value)
	{
		if (candle.State != CandleStates.Finished || !value.IsFormed)
			return;
		_dailyAtr = value.GetValue<decimal>();
	}

	private void ProcessLevel1(Level1ChangeMessage message)
	{
		if (message.TryGetDecimal(Level1Fields.BestBidPrice) is decimal bid && bid > 0m)
			_bestBid = bid;

		if (message.TryGetDecimal(Level1Fields.BestAskPrice) is decimal ask && ask > 0m)
			_bestAsk = ask;

		if (_bestBid is decimal currentBid && _bestAsk is decimal currentAsk)
		{
			if (_sessionDate == message.ServerTime.Date)
			{
				_sessionHigh = Math.Max(_sessionHigh, Math.Max(currentBid, currentAsk));
				_sessionLow = Math.Min(_sessionLow, Math.Min(currentBid, currentAsk));
			}
			if (Position > 0m)
				ApplyProtection(currentBid, currentBid);
			else if (Position < 0m)
				ApplyProtection(currentAsk, currentAsk);
		}

		EvaluateQuote();
	}

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

		var date = candle.OpenTime.Date;
		if (_sessionDate != date)
		{
			if (_sessionDate != default && Position != 0)
				Flatten();

			ResetSession(date);
			_sessionHigh = candle.HighPrice;
			_sessionLow = candle.LowPrice;
			_sessionAtr = _dailyAtr;
			if (_sessionAtr is decimal initialAtr)
				_skipToday = _sessionHigh - _sessionLow > initialAtr * TriggerPercent / 100m;
		}
		else
		{
			_sessionHigh = Math.Max(_sessionHigh, candle.HighPrice);
			_sessionLow = Math.Min(_sessionLow, candle.LowPrice);
		}

		if (Position != 0)
			ApplyProtection(candle.HighPrice, candle.LowPrice);

		EvaluateQuote();
	}

	private void EvaluateQuote()
	{
		if (_sessionAtr is not decimal atr || _sessionDate == default || CurrentTime.Date != _sessionDate || _tradedToday || _skipToday || Position != 0)
			return;
		if (IsPending(_entryOrder) || IsPending(_exitOrder) || !IsFormedAndOnlineAndAllowTrading())
			return;

		if (_bestBid is not decimal bid || _bestAsk is not decimal ask || _sessionLow == 0m || _sessionHigh == 0m)
			return;

		var trigger = atr * TriggerPercent / 100m;
		var residual = atr - trigger;

		var longDistance = bid - _sessionLow;
		var shortDistance = _sessionHigh - ask;

		if (longDistance <= trigger && shortDistance <= trigger)
			return;

		// If both are true on a wide session, take the side with the larger excursion.
		if (longDistance >= shortDistance)
			Enter(Sides.Buy, trigger, residual);
		else
			Enter(Sides.Sell, trigger, residual);
	}

	private static bool IsPending(Order order)
		=> order is not null && order.State is not (OrderStates.Done or OrderStates.Failed);

	private void Enter(Sides side, decimal trigger, decimal residual)
	{
		if (_takeDistance is null)
		{
			_takeDistance = new Unit(residual);
			_stopDistance = new Unit(trigger);
			StartProtection(_takeDistance, _stopDistance, useMarketOrders: true, isLocalStop: true);
		}
		else
		{
			// The native position controller retains these Unit objects. Reconfigure only while flat.
			_takeDistance.Value = residual;
			_stopDistance.Value = trigger;
		}
		_tradedToday = true;
		_entrySide = side;
		_entryVolume = _entryValue = 0m;
		_entryOrder = side == Sides.Buy ? BuyMarket() : SellMarket();
	}

	private void ProcessTrade(MyTrade trade)
	{
		if (trade.Order.Side == _entrySide)
		{
			_entryVolume += trade.Trade.Volume;
			_entryValue += trade.Trade.Price * trade.Trade.Volume;
		}
		else if (_entryVolume > 0m)
		{
			var closed = Math.Min(_entryVolume, trade.Trade.Volume);
			_entryValue -= _entryValue / _entryVolume * closed;
			_entryVolume -= closed;
		}
		if (_entryVolume > 0m)
		{
			var entry = _entryValue / _entryVolume;
			var direction = _entrySide == Sides.Buy ? 1m : -1m;
			_stopPrice = entry - direction * _stopDistance.Value;
			_takePrice = entry + direction * _takeDistance.Value;
		}
		else
		{
			_entryValue = 0m;
			_stopPrice = _takePrice = null;
		}
	}

	private void ApplyProtection(decimal high, decimal low)
	{
		if (Position > 0 &&
			((_stopPrice is decimal longStop && low <= longStop) || (_takePrice is decimal longTake && high >= longTake)))
			Flatten();
		else if (Position < 0 &&
			((_stopPrice is decimal shortStop && high >= shortStop) || (_takePrice is decimal shortTake && low <= shortTake)))
			Flatten();
	}

	private void Flatten()
	{
		if (IsPending(_exitOrder))
			return;
		if (Position > 0)
			_exitOrder = SellMarket(Math.Abs(Position));
		else if (Position < 0)
			_exitOrder = BuyMarket(Math.Abs(Position));
	}

	private void ResetSession(DateTime date)
	{
		_sessionDate = date;
		_sessionAtr = null;
		_sessionHigh = 0m;
		_sessionLow = 0m;
		_tradedToday = false;
		_skipToday = false;
		_stopPrice = null;
		_takePrice = null;
	}
}