Ver en GitHub

Estrategia de Estadística de Comportamiento Repetido

Estrategia intradía que estudia cómo se comportaron las velas a la misma hora del día durante las últimas N sesiones de trading. Para cada nueva barra compara los tamaños acumulados de cuerpos alcistas y bajistas de días anteriores. Si la presión alcista domina, abre una posición larga al cierre de la barra; de lo contrario, va en corto. Las posiciones se cierran en la siguiente barra y un stop loss fijo en pips imita la lógica original de MetaTrader. El tamaño de la posición sigue un martingale de proporción áurea, creciendo tras pérdidas y reiniciándose tras ganancias.

Lógica de trading

  1. Al inicio de cada nueva vela, cerrar cualquier posición abierta de la barra anterior.
  2. Buscar velas de los últimos HistoryDays días de trading que abrieron a la misma hora y minuto.
  3. Sumar los cuerpos de las velas (en puntos) por separado para cierres alcistas y bajistas, ignorando cuerpos menores que MinimumBodyPoints.
  4. Si la suma alcista supera la bajista → abrir una posición larga con el volumen actual.
  5. Si la suma bajista supera la alcista → abrir una posición corta.
  6. Aplicar un stop loss de StopLossPips convertido a través del paso mínimo de precio del instrumento. El stop se comprueba contra los extremos intrabarra cuando la vela finaliza.
  7. Cuando se cierra la operación:
    • Si el resultado es rentable, restablecer el volumen a InitialVolume.
    • De lo contrario, multiplicar el volumen actual por MartingaleFactor (respetando el paso de volumen y los límites).

Parámetros

  • HistoryDays (predeterminado: 10) — número de días anteriores a incluir en las estadísticas.
  • MinimumBodyPoints (predeterminado: 10) — las velas con un cuerpo menor a este umbral (en puntos) se ignoran.
  • StopLossPips (predeterminado: 15) — distancia en pips del stop de protección.
  • InitialVolume (predeterminado: 0.1) — tamaño inicial de la orden antes de ajustes por martingale.
  • MartingaleFactor (predeterminado: 1.618) — multiplicador aplicado tras una operación perdedora.
  • CandleType (predeterminado: 1 hora) — marco temporal utilizado para las velas.

Características de trading

  • Lado del mercado: Ambos, largo y corto, dependiendo de las estadísticas.
  • Marco temporal: Configurable (horario por defecto) con coincidencia exacta por hora y minuto.
  • Gestión de posición: Una sola posición a la vez, cerrada en la siguiente barra o cuando se activa el stop loss.
  • Riesgo: Usa stop fijo en pips y sizing por martingale, que puede aumentar el volumen rápidamente tras pérdidas consecutivas.
  • Instrumentos: Funciona con instrumentos que proporcionan un MinPriceStep válido y límites de volumen.

Notas de implementación

  • Los cuerpos de las velas se almacenan por minuto del día en una cola deslizante limitada por HistoryDays.
  • Los volúmenes se normalizan al paso de volumen del instrumento y están acotados por MinVolume/MaxVolume.
  • La detección del stop loss depende de los extremos de la vela completada para emular la ejecución intrabarra del experto MQL5 original.
  • Todos los comentarios de código en línea están en inglés para cumplir con los requisitos del repositorio.
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;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Strategy that analyzes historical candle bodies for the same time of day.
/// Opens a position when bullish or bearish pressure dominates over recent days.
/// Implements simple martingale sizing after losing trades.
/// </summary>
public class StatisticsRepeatingBehaviorStrategy : Strategy
{
	private readonly StrategyParam<int> _historyDays;
	private readonly StrategyParam<int> _minimumBodyPoints;
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<decimal> _martingaleFactor;
	private readonly StrategyParam<DataType> _candleType;

	private readonly System.Collections.Concurrent.ConcurrentDictionary<int, BodyStatistics> _bodyStatistics = new();

	private decimal _currentVolume;
	private decimal _entryPrice;
	private decimal _stopPrice;
	private int _positionDirection;
	private decimal _priceStep;
	private TimeSpan _timeFrame;

	/// <summary>
	/// Number of historical days to aggregate for statistics.
	/// </summary>
	public int HistoryDays
	{
		get => _historyDays.Value;
		set => _historyDays.Value = value;
	}

	/// <summary>
	/// Minimum body size in points for a candle to contribute into the statistics.
	/// </summary>
	public int MinimumBodyPoints
	{
		get => _minimumBodyPoints.Value;
		set => _minimumBodyPoints.Value = value;
	}

	/// <summary>
	/// Stop loss distance measured in pips.
	/// </summary>
	public int StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Initial order size used before applying martingale adjustments.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the order size after a losing trade.
	/// </summary>
	public decimal MartingaleFactor
	{
		get => _martingaleFactor.Value;
		set => _martingaleFactor.Value = value;
	}

	/// <summary>
	/// Candle type to analyze.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of <see cref="StatisticsRepeatingBehaviorStrategy"/>.
	/// </summary>
	public StatisticsRepeatingBehaviorStrategy()
	{
		_historyDays = Param(nameof(HistoryDays), 3)
			.SetGreaterThanZero()
			.SetDisplay("History Days", "Number of days to collect statistics", "Parameters")
			;

		_minimumBodyPoints = Param(nameof(MinimumBodyPoints), 0)
			.SetDisplay("Minimum Body (points)", "Ignore candles with smaller body", "Parameters")
			;

		_stopLossPips = Param(nameof(StopLossPips), 15)
			.SetGreaterThanZero()
			.SetDisplay("Stop Loss (pips)", "Stop loss distance in pips", "Risk")
			;

		_initialVolume = Param(nameof(InitialVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Initial Volume", "Starting order size", "Trading")
			;

		_martingaleFactor = Param(nameof(MartingaleFactor), 1.618m)
			.SetGreaterThanZero()
			.SetDisplay("Martingale Factor", "Multiplier after losing trade", "Trading")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Candles for analysis", "General");
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();

		_bodyStatistics.Clear();
		_currentVolume = 0m;
		_entryPrice = 0m;
		_stopPrice = 0m;
		_positionDirection = 0;
		_priceStep = 0m;
		_timeFrame = TimeSpan.Zero;
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		_priceStep = Security.PriceStep ?? 1m;

		_timeFrame = CandleType.Arg is TimeSpan span ? span : TimeSpan.Zero;
		if (_timeFrame <= TimeSpan.Zero)
			_timeFrame = TimeSpan.FromMinutes(1);

		_currentVolume = AdjustVolume(InitialVolume);

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

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

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

		var nextOpen = candle.OpenTime + _timeFrame;
		var nextKey = GetMinuteKey(nextOpen);

		// Close existing position at the beginning of the new bar.
		if (_positionDirection != 0)
		{
			var exitPrice = candle.ClosePrice;
			var stopHit = false;

			if (_positionDirection > 0)
			{
				if (candle.LowPrice <= _stopPrice)
				{
					exitPrice = _stopPrice;
					stopHit = true;
				}
			}
			else
			{
				if (candle.HighPrice >= _stopPrice)
				{
					exitPrice = _stopPrice;
					stopHit = true;
				}
			}

			if (Position > 0)
				SellMarket();
			else if (Position < 0)
				BuyMarket();

			UpdateVolumeAfterTrade(exitPrice, stopHit);
		}

		if (_positionDirection == 0 && _bodyStatistics.TryGetValue(nextKey, out var stats) && stats.Count > 0)
		{
			var bullSum = stats.BullSum;
			var bearSum = stats.BearSum;

			if (bullSum > bearSum && Position <= 0)
			{
				EnterPosition(candle, true);
			}
			else if (bearSum > bullSum && Position >= 0)
			{
				EnterPosition(candle, false);
			}
		}

		UpdateStatistics(candle);
	}

	private void EnterPosition(ICandleMessage candle, bool isLong)
	{
		var volume = _currentVolume;
		if (volume <= 0m)
			return;

		var stopDistance = StopLossPips * _priceStep;
		if (stopDistance <= 0m)
			stopDistance = _priceStep;

		if (isLong)
		{
			BuyMarket();
			_entryPrice = candle.ClosePrice;
			_stopPrice = _entryPrice - stopDistance;
			_positionDirection = 1;
		}
		else
		{
			SellMarket();
			_entryPrice = candle.ClosePrice;
			_stopPrice = _entryPrice + stopDistance;
			_positionDirection = -1;
		}
	}

	private void UpdateVolumeAfterTrade(decimal exitPrice, bool stopHit)
	{
		if (_positionDirection == 0)
			return;

		var profit = (_positionDirection > 0 ? exitPrice - _entryPrice : _entryPrice - exitPrice);

		if (profit > 0m && !stopHit)
		{
			_currentVolume = AdjustVolume(InitialVolume);
		}
		else
		{
			var increased = AdjustVolume(InitialVolume * MartingaleFactor);
			_currentVolume = increased;
		}

		_entryPrice = 0m;
		_stopPrice = 0m;
		_positionDirection = 0;
	}

	private void UpdateStatistics(ICandleMessage candle)
	{
		var currentKey = GetMinuteKey(candle.OpenTime);
		var stats = _bodyStatistics.GetOrAdd(currentKey, _ => new BodyStatistics());

		var body = candle.ClosePrice - candle.OpenPrice;
		var bodyPoints = body / _priceStep;
		var absBody = Math.Abs(bodyPoints);

		if (MinimumBodyPoints > 0 && absBody < MinimumBodyPoints)
			return;

		stats.Enqueue(bodyPoints);

		while (stats.Count > HistoryDays)
		{
			var removed = stats.Dequeue();
			if (removed > 0m)
				stats.BullSum -= removed;
			else if (removed < 0m)
				stats.BearSum -= Math.Abs(removed);
		}
	}

	private decimal AdjustVolume(decimal volume)
	{
		return volume <= 0m ? 1m : volume;
	}

	private static int GetMinuteKey(DateTimeOffset time)
	{
		return time.Hour * 60 + time.Minute;
	}

	private sealed class BodyStatistics
	{
		private readonly Queue<decimal> _values = new();

		public decimal BullSum { get; set; }
		public decimal BearSum { get; set; }

		public int Count => _values.Count;

		public void Enqueue(decimal value)
		{
			_values.Enqueue(value);
			if (value > 0m)
				BullSum += value;
			else if (value < 0m)
				BearSum += Math.Abs(value);
		}

		public decimal Dequeue()
		{
			return _values.Dequeue();
		}
	}
}