Открыть на GitHub

Стратегия Proper Bot

Обзор

Proper Bot – сеточная торговая система, перенесённая из советника MetaTrader 4 "Proper Bot". Стратегия запускает направленный портфель ордеров, расширяет его по заранее заданной карте расстояний и объёмов и управляет всем циклом с помощью фильтров по времени, объёму и цене. Реализация на C# использует высокоуровневые подписки на свечи и индикаторы StockSharp.

Логика работы

  1. Определение сигнала
    • При активном EMA-фильтре стратегия рассчитывает три экспоненциальные средние на выбранной серии свечей. Пересечение быстрой и медленной средних задаёт направление, а средняя линия блокирует неподтверждённые сигналы.
    • При отключенном фильтре используется направление тела предыдущей завершённой свечи.
  2. Предторговые фильтры
    • Простое скользящее среднее объёма контролирует минимальный допустимый уровень активности.
    • Торговля доступна только внутри заданного торгового окна.
    • Ценовые уровни HighLevel и LowLevel запрещают покупки слишком высоко и продажи слишком низко; выход цены за пределы уровней может принудительно открыть позицию в соответствующем направлении.
  3. Расширение сетки
    • Первую сделку стратегия открывает объёмом FirstVolume. Дальнейшие приращения соответствуют парам расстояние/объём из строки GridMap. Как только цена проходит против текущей позиции указанное количество пунктов, добавляется новый ордер заданного объёма.
    • Расстояния переводятся в цену через шаг цены инструмента (PriceStep). Если значение недоступно, используется 0.0001.
  4. Управление рисками
    • Вся сетка разделяет общий тейк-профит и стоп-лосс, рассчитываемые от взвешенной средней входа.
    • Суммарная плавающая прибыль контролируется трейлинг-алгоритмом: после достижения порога любое снижение прибыли больше TrailStepPoints закрывает все позиции.
    • При срабатывании любого условия выхода стратегия закрывает весь портфель рыночным ордером и сбрасывает состояние сетки.

Параметры

Параметр Описание Значение по умолчанию
FastMaPeriod Период быстрой EMA в фильтре входа. 10
MidMaPeriod Период промежуточной EMA; 0 отключает фильтр. 25
SlowMaPeriod Период медленной EMA в фильтре входа. 50
DisableMaFilter Игнорировать EMA и следовать направлению предыдущей свечи. true
VolumePeriod Число свечей в среднем объёма; 0 отключает фильтр. 1
VolumeMinimum Минимальный средний объём для открытия позиций. 69
HighLevel Уровень, выше которого покупки запрещены и возможна принудительная продажа. 1.50001
LowLevel Уровень, ниже которого продажи запрещены и возможна принудительная покупка. 1.40001
FirstVolume Объём первого ордера в каждом цикле сетки. 0.08
GridMap Список пар расстояние/объём, разделённых пробелами, задающих шаги сетки. 120/0.1 ... 120/0.19
TakeProfitPoints Расстояние тейк-профита (в шагах цены) для общего портфеля. 10000
StopLossPoints Расстояние стоп-лосса (в шагах цены) для общего портфеля. 30000
TrailStartPoints Минимальная прибыль для активации трейлинг-контроля. 52
TrailDistancePoints Прибыль, которую нужно набрать (минус шаг трейлинга), чтобы запустить сопровождение. 52
TrailStepPoints Допустимая отдача прибыли после активации трейлинга. 2
StartHour / StartMinute Начало торговой сессии (включительно). 06:00
FinishHour / FinishMinute Конец торговой сессии (включительно, допускает переход через ночь). 21:00
CandleType Тип свечей, обрабатываемых стратегией. Таймфрейм 1 минута

Практические замечания

  • GridMap анализируется в инвариантной культуре: до косой черты указываются пункты, после – объём.
  • Все дистанции преобразуются через PriceStep инструмента. Перед запуском убедитесь, что шаг цены задан корректно.
  • Трейлинг оценивает суммарную плавающую прибыль по завершённым свечам. Для более оперативных выходов используйте более мелкий таймфрейм.
  • Принудительные входы на пробое уровней используют цену закрытия свечи как приближение bid/ask.
  • В отличие от MT4-версии, порт на StockSharp закрывает весь портфель при выполнении условий тейка, стопа или трейлинга, упрощая управление через высокоуровневый API.

Отличия от версии MT4

  • В MT4 каждому ордеру назначались индивидуальные защитные уровни; здесь расчёт ведётся по агрегированной позиции.
  • Bid/ask оцениваются по цене закрытия свечи, так как свечные данные не содержат спредов.
  • Порог активации трейлинга равен максимуму между TrailDistancePoints - TrailStepPoints и TrailStartPoints, что стабилизирует работу при пересечении параметров.
  • Торговые часы привязаны к DateTimeOffset свечи. Убедитесь, что источник данных выдаёт время в нужной зоне.

Файлы

  • CS/ProperBotStrategy.cs – реализация стратегии.
  • README.md – описание на английском.
  • README_zh.md – описание на китайском.
  • README_ru.md – описание на русском (этот файл).
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;
using StockSharp.Algo;

namespace StockSharp.Samples.Strategies;



/// <summary>
/// Grid strategy converted from the "Proper Bot" MQL expert advisor.
/// </summary>
public class ProperBotStrategy : Strategy
{
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<int> _midPeriod;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<bool> _disableMaFilter;
	private readonly StrategyParam<int> _volumePeriod;
	private readonly StrategyParam<decimal> _volumeMinimum;
	private readonly StrategyParam<decimal> _highLevel;
	private readonly StrategyParam<decimal> _lowLevel;
	private readonly StrategyParam<decimal> _firstVolume;
	private readonly StrategyParam<string> _gridMap;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _trailStartPoints;
	private readonly StrategyParam<int> _trailDistancePoints;
	private readonly StrategyParam<int> _trailStepPoints;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _startMinute;
	private readonly StrategyParam<int> _finishHour;
	private readonly StrategyParam<int> _finishMinute;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<GridLevel> _gridLevels = new();
	private readonly List<GridOrder> _activeOrders = new();

	private ExponentialMovingAverage _fastEma;
	private ExponentialMovingAverage _midEma;
	private ExponentialMovingAverage _slowEma;
	private SimpleMovingAverage _volumeAverage;

	private decimal _priceStep;
	private Sides? _currentDirection;
	private int _nextGridIndex;
	private decimal _lastEntryPrice;
	private decimal _maxTrailingPoints;
	private bool _hasPreviousCandle;
	private decimal _previousOpen;
	private decimal _previousClose;
	private int _previousSignal;

	public int FastMaPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	public int MidMaPeriod
	{
		get => _midPeriod.Value;
		set => _midPeriod.Value = value;
	}

	public int SlowMaPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	public bool DisableMaFilter
	{
		get => _disableMaFilter.Value;
		set => _disableMaFilter.Value = value;
	}

	public int VolumePeriod
	{
		get => _volumePeriod.Value;
		set => _volumePeriod.Value = value;
	}

	public decimal VolumeMinimum
	{
		get => _volumeMinimum.Value;
		set => _volumeMinimum.Value = value;
	}

	public decimal HighLevel
	{
		get => _highLevel.Value;
		set => _highLevel.Value = value;
	}

	public decimal LowLevel
	{
		get => _lowLevel.Value;
		set => _lowLevel.Value = value;
	}

	public decimal FirstVolume
	{
		get => _firstVolume.Value;
		set => _firstVolume.Value = value;
	}

	public string GridMap
	{
		get => _gridMap.Value;
		set => _gridMap.Value = value;
	}

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

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

	public int TrailStartPoints
	{
		get => _trailStartPoints.Value;
		set => _trailStartPoints.Value = value;
	}

	public int TrailDistancePoints
	{
		get => _trailDistancePoints.Value;
		set => _trailDistancePoints.Value = value;
	}

	public int TrailStepPoints
	{
		get => _trailStepPoints.Value;
		set => _trailStepPoints.Value = value;
	}

	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	public int StartMinute
	{
		get => _startMinute.Value;
		set => _startMinute.Value = value;
	}

	public int FinishHour
	{
		get => _finishHour.Value;
		set => _finishHour.Value = value;
	}

	public int FinishMinute
	{
		get => _finishMinute.Value;
		set => _finishMinute.Value = value;
	}

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

	public ProperBotStrategy()
	{
		_fastPeriod = Param(nameof(FastMaPeriod), 10)
			.SetGreaterThanZero()
			.SetDisplay("Fast EMA", "Period of the fast EMA filter", "Signals")
			;

		_midPeriod = Param(nameof(MidMaPeriod), 25)
			.SetDisplay("Mid EMA", "Optional middle EMA period", "Signals")
			;

		_slowPeriod = Param(nameof(SlowMaPeriod), 50)
			.SetGreaterThanZero()
			.SetDisplay("Slow EMA", "Period of the slow EMA filter", "Signals")
			;

		_disableMaFilter = Param(nameof(DisableMaFilter), true)
			.SetDisplay("Disable EMA Filter", "Use previous candle direction instead of EMAs", "Signals")
			;

		_volumePeriod = Param(nameof(VolumePeriod), 1)
			.SetDisplay("Volume Period", "Number of candles for the volume filter", "Filters")
			;

		_volumeMinimum = Param(nameof(VolumeMinimum), 0m)
			.SetDisplay("Volume Minimum", "Minimal average volume to allow entries", "Filters")
			;

		_highLevel = Param(nameof(HighLevel), 1000000m)
			.SetDisplay("High Level", "Do not buy above this price", "Filters")
			;

		_lowLevel = Param(nameof(LowLevel), -1000000m)
			.SetDisplay("Low Level", "Do not sell below this price", "Filters")
			;

		_firstVolume = Param(nameof(FirstVolume), 0.08m)
			.SetDisplay("First Order Volume", "Volume for the first order in a grid cycle", "Risk")
			;

		_gridMap = Param(nameof(GridMap), string.Empty)
			.SetDisplay("Grid Map", "Distance/volume pairs separated by spaces", "Risk");

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 10000)
			.SetDisplay("Take Profit Points", "Profit distance in price steps", "Risk")
			;

		_stopLossPoints = Param(nameof(StopLossPoints), 30000)
			.SetDisplay("Stop Loss Points", "Loss distance in price steps", "Risk")
			;

		_trailStartPoints = Param(nameof(TrailStartPoints), 52)
			.SetDisplay("Trail Start Points", "Minimal profit to arm the trailing exit", "Risk")
			;

		_trailDistancePoints = Param(nameof(TrailDistancePoints), 52)
			.SetDisplay("Trail Distance Points", "Profit distance required to enable trailing", "Risk")
			;

		_trailStepPoints = Param(nameof(TrailStepPoints), 2)
			.SetDisplay("Trail Step Points", "Allowed profit retracement before exit", "Risk")
			;

		_startHour = Param(nameof(StartHour), 0)
			.SetDisplay("Start Hour", "Trading session start hour", "Session")
			;

		_startMinute = Param(nameof(StartMinute), 0)
			.SetDisplay("Start Minute", "Trading session start minute", "Session")
			;

		_finishHour = Param(nameof(FinishHour), 23)
			.SetDisplay("Finish Hour", "Trading session end hour", "Session")
			;

		_finishMinute = Param(nameof(FinishMinute), 0)
			.SetDisplay("Finish Minute", "Trading session end minute", "Session")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles to process", "General");
	}

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

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

		_gridLevels.Clear();
		_activeOrders.Clear();
		_fastEma = null;
		_midEma = null;
		_slowEma = null;
		_volumeAverage = null;
		_priceStep = 0m;
		_currentDirection = null;
		_nextGridIndex = 0;
		_lastEntryPrice = 0m;
		_maxTrailingPoints = decimal.MinValue;
		Volume = FirstVolume;
		_hasPreviousCandle = false;
		_previousOpen = 0m;
		_previousClose = 0m;
		_previousSignal = 0;
	}

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

		ParseGridMap();

		_fastEma = new ExponentialMovingAverage { Length = Math.Max(1, FastMaPeriod) };
		_midEma = new ExponentialMovingAverage { Length = Math.Max(1, MidMaPeriod) };
		_slowEma = new ExponentialMovingAverage { Length = Math.Max(1, SlowMaPeriod) };
		_volumeAverage = new SimpleMovingAverage { Length = Math.Max(1, VolumePeriod) };

		_priceStep = Security?.PriceStep ?? 0.0001m;
		if (_priceStep <= 0m)
			_priceStep = 0.0001m;

		_maxTrailingPoints = decimal.MinValue;

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(_fastEma, _midEma, _slowEma, ProcessCandle)
			.Start();

		StartProtection(
			takeProfit: new Unit(2, UnitTypes.Percent),
			stopLoss: new Unit(1, UnitTypes.Percent));
	}

	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		if (trade == null)
			return;

		var direction = trade.Order.Side;
		var volume = trade.Trade.Volume;

		if (volume <= 0m)
			return;

		if (_currentDirection is null)
			_currentDirection = direction;

		if (_currentDirection == direction)
		{
			_activeOrders.Add(new GridOrder(trade.Trade.Price, volume));
			_lastEntryPrice = trade.Trade.Price;

			if (_activeOrders.Count <= 1)
			{
				_nextGridIndex = 0;
				_maxTrailingPoints = decimal.MinValue;
			}
			else
			{
				_nextGridIndex = Math.Min(_activeOrders.Count - 1, Math.Max(0, _gridLevels.Count - 1));
			}
		}
		else
		{
			ReducePosition(volume);

			if (_activeOrders.Count == 0)
			{
				_currentDirection = null;
				_lastEntryPrice = 0m;
				_nextGridIndex = 0;
				_maxTrailingPoints = decimal.MinValue;
			}
			else
			{
				_lastEntryPrice = _activeOrders[^1].Price;
				_nextGridIndex = Math.Min(_activeOrders.Count - 1, Math.Max(0, _gridLevels.Count - 1));
			}
		}
	}

	private void ProcessCandle(ICandleMessage candle, decimal fastValue, decimal midValue, decimal slowValue)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var signal = CalculateSignal(fastValue, midValue, slowValue);

		if (Position == 0 && signal != 0 && signal != _previousSignal)
		{
			if (signal > 0)
				BuyMarket();
			else
				SellMarket();
		}

		UpdatePreviousCandle(candle);
		_previousSignal = signal;
	}

	private int CalculateSignal(decimal fastValue, decimal midValue, decimal slowValue)
	{
		if (DisableMaFilter)
		{
			if (!_hasPreviousCandle)
				return 0;

			if (_previousClose > _previousOpen)
				return 1;

			if (_previousClose < _previousOpen)
				return -1;

			return 0;
		}

		if (!_slowEma.IsFormed)
			return 0;

		var fastSignal = fastValue.CompareTo(slowValue);

		if (fastSignal == 0)
			return 0;

		var signal = fastSignal > 0 ? 1 : -1;

		if (MidMaPeriod > 0)
		{
			if (!_midEma.IsFormed)
				return 0;

			if ((midValue >= fastValue && fastValue > slowValue) || (midValue <= fastValue && fastValue < slowValue))
				return 0;
		}

		return signal;
	}

	private bool CheckVolume(ICandleMessage candle)
	{
		if (VolumePeriod < 1)
			return true;

		var average = _volumeAverage.Process(new DecimalIndicatorValue(_volumeAverage, candle.TotalVolume, candle.CloseTime) { IsFinal = true }).ToDecimal();

		if (!_volumeAverage.IsFormed)
			return false;

		return average >= VolumeMinimum;
	}

	private void ApplyBoundaryFilters(ref int signal, decimal price)
	{
		var ask = price;
		var bid = price;

		if (ask > HighLevel)
			signal = 0;

		if (bid < LowLevel)
			signal = 0;

		if (ask < LowLevel)
			signal = 1;

		if (bid > HighLevel)
			signal = -1;
	}

	private bool IsWithinTradingHours(DateTimeOffset time)
	{
		var start = new TimeSpan(StartHour, StartMinute, 0);
		var end = new TimeSpan(FinishHour, FinishMinute, 0);
		var current = time.TimeOfDay;

		if (start == end)
			return true;

		if (start < end)
			return current >= start && current <= end;

		return current >= start || current <= end;
	}

	private void StartNewCycle(int signal)
	{
		if (FirstVolume <= 0m)
			return;

		if (signal > 0)
		{
			BuyMarket();
		}
		else if (signal < 0)
		{
			SellMarket();
		}
	}

	private bool ManageRisk(ICandleMessage candle)
	{
		if (_currentDirection is null || _activeOrders.Count == 0)
			return false;

		var averagePrice = CalculateAveragePrice();
		var close = candle.ClosePrice;
		var high = candle.HighPrice;
		var low = candle.LowPrice;
		var direction = _currentDirection == Sides.Buy ? 1m : -1m;
		var takeProfitDistance = ConvertPointsToPrice(TakeProfitPoints);
		var stopLossDistance = ConvertPointsToPrice(StopLossPoints);

		if (direction > 0)
		{
			if (TakeProfitPoints > 0 && high - averagePrice >= takeProfitDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}

			if (StopLossPoints > 0 && averagePrice - low >= stopLossDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}
		}
		else
		{
			if (TakeProfitPoints > 0 && averagePrice - low >= takeProfitDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}

			if (StopLossPoints > 0 && high - averagePrice >= stopLossDistance)
			{
				if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
				return true;
			}
		}

		if (TrailDistancePoints > 0 && TrailStepPoints > 0)
		{
			var points = CalculateFloatingPoints(close);
			var activation = (decimal)Math.Max(TrailDistancePoints - TrailStepPoints, TrailStartPoints);

			if (points >= activation)
			{
				if (_maxTrailingPoints == decimal.MinValue || points > _maxTrailingPoints)
				{
					_maxTrailingPoints = points;
				}
				else if (_maxTrailingPoints - points >= TrailStepPoints)
				{
					if (Position > 0) SellMarket(); else if (Position < 0) BuyMarket();
					return true;
				}
			}
		}

		return false;
	}

	private void ProcessGridExpansion(ICandleMessage candle)
	{
		if (_currentDirection is null || _activeOrders.Count == 0 || _gridLevels.Count == 0)
			return;

		var index = Math.Min(_nextGridIndex, _gridLevels.Count - 1);
		var level = _gridLevels[index];
		var distance = level.Distance * _priceStep;

		if (distance <= 0m)
			return;

		if (_currentDirection == Sides.Buy)
		{
			if (_lastEntryPrice - candle.LowPrice >= distance)
			{
				if (level.Volume > 0m)
					BuyMarket();
			}
		}
		else
		{
			if (candle.HighPrice - _lastEntryPrice >= distance)
			{
				if (level.Volume > 0m)
					SellMarket();
			}
		}
	}

	private void UpdatePreviousCandle(ICandleMessage candle)
	{
		_previousOpen = candle.OpenPrice;
		_previousClose = candle.ClosePrice;
		_hasPreviousCandle = true;
	}

	private decimal CalculateAveragePrice()
	{
		if (_activeOrders.Count == 0)
			return 0m;

		decimal sum = 0m;
		decimal volume = 0m;

		for (var i = 0; i < _activeOrders.Count; i++)
		{
			var order = _activeOrders[i];
			sum += order.Price * order.Volume;
			volume += order.Volume;
		}

		return volume > 0m ? sum / volume : 0m;
	}

	private decimal CalculateFloatingPoints(decimal price)
	{
		if (_activeOrders.Count == 0 || _priceStep <= 0m || _currentDirection is null)
			return 0m;

		var direction = _currentDirection == Sides.Buy ? 1m : -1m;
		decimal sum = 0m;

		for (var i = 0; i < _activeOrders.Count; i++)
		{
			var order = _activeOrders[i];
			sum += (price - order.Price) * direction / _priceStep;
		}

		return sum;
	}

	private decimal ConvertPointsToPrice(int points)
		=> points <= 0 ? 0m : points * _priceStep;

	private bool HasActiveCycle()
		=> _activeOrders.Count > 0;

	private void ReducePosition(decimal volume)
	{
		var remaining = volume;

		for (var i = _activeOrders.Count - 1; i >= 0 && remaining > 0m; i--)
		{
			var order = _activeOrders[i];

			if (order.Volume > remaining)
			{
				order.Volume -= remaining;
				remaining = 0m;
			}
			else
			{
				remaining -= order.Volume;
				_activeOrders.RemoveAt(i);
			}
		}
	}

	private void ParseGridMap()
	{
		_gridLevels.Clear();

		if (GridMap.IsEmptyOrWhiteSpace())
			return;

		var parts = GridMap.Split(new[] { ' ', ';', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);

		foreach (var part in parts)
		{
			var tokens = part.Split('/');

			if (tokens.Length != 2)
				continue;

			if (!decimal.TryParse(tokens[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var distance))
				continue;

			if (!decimal.TryParse(tokens[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var volume))
				continue;

			if (distance <= 0m || volume <= 0m)
				continue;

			_gridLevels.Add(new GridLevel(distance, volume));
		}
	}

	private sealed class GridOrder
	{
		public GridOrder(decimal price, decimal volume)
		{
			Price = price;
			Volume = volume;
		}

		public decimal Price { get; set; }

		public decimal Volume { get; set; }
	}

	private readonly record struct GridLevel(decimal Distance, decimal Volume);
}